P6: security soak, axe a11y sign-off, signed air-gapped bundle tooling

- Concurrency soak (exit criterion): 12 concurrent gated runs, every
  decision attempted twice concurrently, explicit resumes racing the
  durable sweeper — exactly one execution per approval, grants consumed
  at most once, every decision audited, zero stuck runs, zero unaudited
  executions. (Testkit pool raised to 20 connections; the 5-connection
  pool starved the storm.)
- axe a11y sweep (exit criterion): serious+critical violations fail CI on
  login, shell, chat, computer home, settings app, all global pages, and
  the wizard. Two real violations found and fixed: aria-label on a plain
  div (wizard progress -> role=group) and a button directly inside a <dl>
  (settings -> plain bordered list).
- tools/bundler (exit criterion): keygen / assemble / verify CLI — copies
  artifacts, writes manifest.json + sha256 checksums.txt + a detached
  ed25519 signature; verification is fully offline (keyless signing is
  internet-dependent and disqualified). Tests: round trip, tampered
  artifact caught by hash, tampered checksum list caught by signature,
  wrong key refused, missing artifact reported.

147 Rust + 63 frontend tests + 27 Playwright journeys (incl. 4 a11y).

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-10 07:36:15 -05:00
co-authored by Claude Fable 5
parent a8efada690
commit ccf96053e6
14 changed files with 728 additions and 6 deletions
Generated
+81
View File
@@ -538,6 +538,33 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.23.0" version = "0.23.0"
@@ -639,6 +666,31 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"rand_core 0.6.4",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "either" name = "either"
version = "1.16.0" version = "1.16.0"
@@ -714,6 +766,12 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]] [[package]]
name = "figment" name = "figment"
version = "0.10.19" version = "0.10.19"
@@ -2210,6 +2268,15 @@ version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.4" version = "1.1.4"
@@ -3130,6 +3197,20 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "teamclaw-bundler"
version = "0.1.0"
dependencies = [
"ed25519-dalek",
"hex",
"rand_core 0.6.4",
"serde",
"serde_json",
"sha2",
"thiserror",
"uuid",
]
[[package]] [[package]]
name = "teamclaw-server" name = "teamclaw-server"
version = "0.1.0" version = "0.1.0"
+1
View File
@@ -18,6 +18,7 @@ members = [
"crates/tc-api", "crates/tc-api",
"crates/bins/teamclaw-server", "crates/bins/teamclaw-server",
"crates/bins/teamclaw-broker", "crates/bins/teamclaw-broker",
"tools/bundler",
] ]
[workspace.package] [workspace.package]
+206
View File
@@ -0,0 +1,206 @@
//! P6 security soak (spec §17 exit): under concurrent load with racing
//! decisions and duplicated resume attempts, ZERO unaudited gated
//! executions — every outbox row maps to exactly one approved decision,
//! every grant is consumed at most once, and nothing executes twice.
use std::sync::Arc;
use std::time::Duration;
use tc_domain::{
AccessPolicy, Agent, AgentId, AgentStatus, Role, RunState, User, UserId, Workspace, WorkspaceId,
};
use tc_llm::ScriptedProvider;
use tc_runtime::{RunEventBody, Runtime, RuntimeConfig};
use tc_safety::{approvals, Decision, ResumeReady};
const SCENARIOS: &str = r#"
[[scenario]]
marker = "[[scenario:gated-email]]"
[[scenario.turns]]
events = [
{ type = "tool_use", name = "email.send", input = { to = "[email protected]", subject = "Soak", body = "Load test." } },
]
[[scenario.turns]]
events = [
{ type = "text", text = "Done." },
]
"#;
const RUNS: usize = 12;
#[tokio::test]
async fn concurrent_gated_runs_never_execute_unaudited_or_twice() {
let pool = tc_testkit::test_pool().await;
let ws = Workspace {
id: WorkspaceId::new(),
name: "Acme".into(),
plan: "team".into(),
};
tc_db::repo::workspaces::insert(&pool, &ws).await.unwrap();
let owner = User {
id: UserId::new(),
workspace_id: ws.id,
email: format!("{}@acme.test", UserId::new()),
role: Role::Owner,
display_name: "Owner".into(),
created_at: time::OffsetDateTime::UNIX_EPOCH,
};
tc_db::repo::users::insert(&pool, &owner).await.unwrap();
let agent = Agent {
id: AgentId::new(),
workspace_id: ws.id,
name: "Scout".into(),
job_title: "Analyst".into(),
system_prompt: String::new(),
avatar: String::new(),
accent: String::new(),
wallpaper: String::new(),
managed_by: owner.id,
status: AgentStatus::Online,
};
tc_db::repo::agents::insert(&pool, &agent, &AccessPolicy::default())
.await
.unwrap();
tc_db::repo::credits::add_lot(&pool, ws.id, 10_000, "soak")
.await
.unwrap();
let rt = Runtime::new(
pool.clone(),
Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()),
RuntimeConfig::basic("scripted", 1024),
);
// The durable sweeper races our explicit resume calls on purpose.
rt.spawn_resume_sweeper(Duration::from_millis(25));
// Launch all runs concurrently, each in its own session.
let mut run_ids = Vec::new();
let mut handles = Vec::new();
for i in 0..RUNS {
let session = tc_db::repo::sessions::create(&pool, agent.id, ws.id, &format!("Soak {i}"))
.await
.unwrap();
let rt = rt.clone();
handles.push(tokio::spawn(async move {
let started = rt
.send_message(session.id, "send it [[scenario:gated-email]]")
.await
.unwrap();
let mut rx = started.events;
while let Ok(envelope) = rx.recv().await {
if matches!(
envelope.event,
RunEventBody::RunSuspended { .. } | RunEventBody::Error { .. }
) {
break;
}
}
started.run_id
}));
}
for handle in handles {
run_ids.push(handle.await.unwrap());
}
// All suspended with pending approvals.
let pending = approvals::list_pending(&pool, ws.id).await.unwrap();
assert_eq!(pending.len(), RUNS);
let outbox_before = sqlx::query_scalar::<_, i64>("SELECT count(*) FROM outbox")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(outbox_before, 0, "nothing may execute while pending");
// Decide concurrently: even-indexed approve, odd-indexed reject — and
// every decision is attempted TWICE concurrently (double-click storm),
// plus an explicit duplicate resume racing the sweeper.
let mut deciders = Vec::new();
for (i, approval) in pending.iter().enumerate() {
let decision = if i % 2 == 0 {
Decision::Approve
} else {
Decision::Reject
};
for _ in 0..2 {
let pool = pool.clone();
let rt = rt.clone();
let id = approval.id;
let run_id = approval.run_id;
let user = owner.id;
deciders.push(tokio::spawn(async move {
let decided = approvals::decide(&pool, id, user, decision).await;
// Exactly one of the two concurrent attempts wins.
let _ = rt
.resume_run(ResumeReady {
run_id,
approval_id: id,
approved: decision == Decision::Approve,
})
.await;
decided.is_ok()
}));
}
}
let mut wins = 0;
for decider in deciders {
if decider.await.unwrap() {
wins += 1;
}
}
assert_eq!(wins, RUNS, "each approval decided exactly once");
// Every run reaches Completed.
for run_id in &run_ids {
let mut done = false;
for _ in 0..200 {
let run = tc_db::repo::runs::get(&pool, *run_id).await.unwrap();
if run.state == RunState::Completed {
done = true;
break;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
assert!(done, "run {run_id} never completed");
}
// THE invariant: executions == approvals approved, exactly.
let approved = RUNS / 2;
let outbox = sqlx::query_scalar::<_, i64>("SELECT count(*) FROM outbox")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
outbox as usize, approved,
"execute once per approval, never more"
);
// Every approved grant consumed exactly once; rejected ones have none.
let consumed =
sqlx::query_scalar::<_, i64>("SELECT count(*) FROM execution_grants WHERE consumed = true")
.fetch_one(&pool)
.await
.unwrap();
let total_grants = sqlx::query_scalar::<_, i64>("SELECT count(*) FROM execution_grants")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(consumed as usize, approved);
assert_eq!(total_grants as usize, approved);
// Every decision audited.
let audited = sqlx::query_scalar::<_, i64>(
"SELECT count(*) FROM audit_log WHERE event_type IN ('approval.approved', 'approval.rejected')",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(audited as usize, RUNS);
// No approval left pending.
assert!(approvals::list_pending(&pool, ws.id)
.await
.unwrap()
.is_empty());
}
+1 -1
View File
@@ -76,7 +76,7 @@ pub async fn test_pool() -> PgPool {
let test_url = swap_database(&server.admin_url, &db_name); let test_url = swap_database(&server.admin_url, &db_name);
let pool = PgPoolOptions::new() let pool = PgPoolOptions::new()
.max_connections(5) .max_connections(20)
.connect(&test_url) .connect(&test_url)
.await .await
.expect("connect test database"); .expect("connect test database");
+24
View File
@@ -17,6 +17,7 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "^4.11.3",
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
@@ -54,6 +55,29 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@axe-core/playwright": {
"version": "4.11.3",
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.3.tgz",
"integrity": "sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"axe-core": "~4.11.4"
},
"peerDependencies": {
"playwright-core": ">= 1.0.0"
}
},
"node_modules/@axe-core/playwright/node_modules/axe-core": {
"version": "4.11.4",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz",
"integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==",
"dev": true,
"license": "MPL-2.0",
"engines": {
"node": ">=4"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+1
View File
@@ -20,6 +20,7 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "^4.11.3",
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
@@ -154,10 +154,10 @@ export default function SettingsApp({ agent }: { agent: Agent }) {
<p className="text-sm font-medium">{live.name}</p> <p className="text-sm font-medium">{live.name}</p>
<p className="text-xs text-muted-foreground">{live.job_title}</p> <p className="text-xs text-muted-foreground">{live.job_title}</p>
</div> </div>
<dl className="rounded-(--radius) border border-border bg-subtle text-xs"> <div className="rounded-(--radius) border border-border bg-subtle text-xs">
<div className="flex justify-between border-b border-border px-2 py-2"> <div className="flex justify-between border-b border-border px-2 py-2">
<dt className="text-muted-foreground">Managed by</dt> <span className="text-muted-foreground">Managed by</span>
<dd>{settings.data?.managed_by_name ?? "…"}</dd> <span>{settings.data?.managed_by_name ?? "…"}</span>
</div> </div>
<button <button
type="button" type="button"
@@ -166,7 +166,7 @@ export default function SettingsApp({ agent }: { agent: Agent }) {
> >
Edit profile <span aria-hidden></span> Edit profile <span aria-hidden></span>
</button> </button>
</dl> </div>
<p className="pt-1 text-xxs uppercase tracking-wide text-muted-foreground"> <p className="pt-1 text-xxs uppercase tracking-wide text-muted-foreground">
Who else has access Who else has access
</p> </p>
@@ -105,7 +105,11 @@ export function CreateClawForm() {
return ( return (
<div className="flex w-full max-w-md flex-col gap-4"> <div className="flex w-full max-w-md flex-col gap-4">
{/* Step progress (§9). */} {/* Step progress (§9). */}
<div className="flex gap-1.5" aria-label={`Step ${stepIndex + 1} of 3`}> <div
role="group"
className="flex gap-1.5"
aria-label={`Step ${stepIndex + 1} of 3`}
>
{WIZARD_STEPS.map((s, i) => ( {WIZARD_STEPS.map((s, i) => (
<span <span
key={s} key={s}
+72
View File
@@ -0,0 +1,72 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test, type Page } from "@playwright/test";
// P6 exit (spec §17): a11y sign-off. Serious and critical axe violations
// fail the build on every primary surface.
const OWNER_EMAIL = "[email protected]";
const OWNER_PASSWORD = "e2e-password";
async function signIn(page: Page) {
await page.goto("/login");
await page.getByLabel("Email").fill(OWNER_EMAIL);
await page.getByLabel("Password").fill(OWNER_PASSWORD);
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("heading", { name: "TeamClaw" })).toBeVisible();
}
async function expectClean(page: Page, context: string) {
const results = await new AxeBuilder({ page }).analyze();
const blocking = results.violations.filter(
(v) => v.impact === "serious" || v.impact === "critical",
);
expect(
blocking.map((v) => `${v.id}: ${v.help} (${v.nodes.length} nodes)`),
`axe violations on ${context}`,
).toEqual([]);
}
test("login page is clean", async ({ page }) => {
await page.goto("/login");
await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible();
await expectClean(page, "login");
});
test("shell, chat, and computer panel are clean", async ({ page }) => {
await signIn(page);
await expectClean(page, "workspace home");
await page.getByRole("link", { name: /Scout/ }).click();
await expect(page).toHaveURL(/\/claws\/.+\/chat\//);
await expectClean(page, "chat");
await page.getByRole("button", { name: "Computer" }).click();
const panel = page.getByRole("complementary", { name: "Computer" });
await expect(panel.getByText(/Scout's Computer/)).toBeVisible();
await expectClean(page, "computer home");
await panel.getByRole("button", { name: "Settings" }).click();
await expect(panel.getByText("Managed by")).toBeVisible();
await expectClean(page, "settings app");
});
test("global pages are clean", async ({ page }) => {
await signIn(page);
for (const [link, marker] of [
["Skills", /Skill Library/],
["Approvals", /review/i],
["Team", /workspace/],
["Credits", /Available credits/],
] as const) {
await page.getByRole("link", { name: link, exact: true }).click();
await expect(page.getByText(marker).first()).toBeVisible();
await expectClean(page, link);
}
});
test("the wizard is clean", async ({ page }) => {
await signIn(page);
await page.getByRole("link", { name: "New claw" }).click();
await expect(page.getByText("Create your Claw")).toBeVisible();
await expectClean(page, "wizard");
});
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "teamclaw-bundler"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
ed25519-dalek = { version = "2", features = ["rand_core"] }
hex = "0.4"
rand_core = { version = "0.6", features = ["getrandom"] }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = "0.10"
thiserror = { workspace = true }
[dev-dependencies]
uuid = { workspace = true }
[lints]
workspace = true
+147
View File
@@ -0,0 +1,147 @@
//! 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(())
}
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": "teamclaw",
"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)
}
+63
View File
@@ -0,0 +1,63 @@
//! teamclaw-bundler: assemble and verify air-gapped install bundles.
//!
//! Usage:
//! teamclaw-bundler keygen <private.key> <public.key>
//! teamclaw-bundler assemble <out-dir> <version> <private.key> \
//! <src=dest> [<src=dest> ...]
//! teamclaw-bundler verify <bundle-dir> <public.key>
use std::path::{Path, PathBuf};
use std::process::ExitCode;
fn main() -> ExitCode {
match run() {
Ok(message) => {
println!("{message}");
ExitCode::SUCCESS
}
Err(message) => {
eprintln!("teamclaw-bundler: {message}");
ExitCode::FAILURE
}
}
}
fn run() -> Result<String, String> {
let args: Vec<String> = std::env::args().skip(1).collect();
match args.first().map(String::as_str) {
Some("keygen") if args.len() == 3 => {
teamclaw_bundler::generate_keypair(Path::new(&args[1]), Path::new(&args[2]))
.map_err(|e| e.to_string())?;
Ok(format!("wrote {} and {}", args[1], args[2]))
}
Some("assemble") if args.len() >= 5 => {
let artifacts: Vec<(PathBuf, String)> = args[4..]
.iter()
.map(|pair| {
pair.split_once('=')
.map(|(src, dst)| (PathBuf::from(src), dst.to_owned()))
.ok_or_else(|| format!("expected src=dest, got {pair}"))
})
.collect::<Result<_, _>>()?;
teamclaw_bundler::assemble(
Path::new(&args[1]),
&args[2],
&artifacts,
Path::new(&args[3]),
)
.map_err(|e| e.to_string())?;
Ok(format!(
"assembled {} artifacts into {}",
artifacts.len(),
args[1]
))
}
Some("verify") if args.len() == 3 => {
let verified =
teamclaw_bundler::verify(Path::new(&args[1]), Path::new(&args[2]))
.map_err(|e| e.to_string())?;
Ok(format!("bundle OK: {verified} artifacts verified offline"))
}
_ => Err("usage: keygen <priv> <pub> | assemble <out> <version> <priv> <src=dest>... | verify <bundle> <pub>".into()),
}
}
+100
View File
@@ -0,0 +1,100 @@
use std::fs;
use std::path::PathBuf;
use teamclaw_bundler::{assemble, generate_keypair, verify, BundleError};
struct Fixture {
root: PathBuf,
public_key: PathBuf,
bundle: PathBuf,
}
fn fixture() -> Fixture {
let root = std::env::temp_dir().join(format!("tc-bundle-{}", uuid::Uuid::now_v7()));
fs::create_dir_all(&root).unwrap();
let private_key = root.join("release.key");
let public_key = root.join("release.pub");
generate_keypair(&private_key, &public_key).unwrap();
fs::write(root.join("server.tar"), b"pretend image bytes").unwrap();
fs::write(root.join("compose.yml"), b"services: {}").unwrap();
fs::write(root.join("0001_init.sql"), b"CREATE TABLE t ();").unwrap();
let bundle = root.join("bundle");
assemble(
&bundle,
"1.2.3",
&[
(root.join("server.tar"), "images/server.tar".into()),
(
root.join("compose.yml"),
"compose/docker-compose.yml".into(),
),
(
root.join("0001_init.sql"),
"migrations/0001_init.sql".into(),
),
],
&private_key,
)
.unwrap();
Fixture {
root,
public_key,
bundle,
}
}
#[test]
fn assembled_bundles_verify_offline() {
let fx = fixture();
// 3 artifacts + manifest.json.
assert_eq!(verify(&fx.bundle, &fx.public_key).unwrap(), 4);
let manifest: serde_json::Value =
serde_json::from_str(&fs::read_to_string(fx.bundle.join("manifest.json")).unwrap())
.unwrap();
assert_eq!(manifest["version"], "1.2.3");
assert_eq!(manifest["artifacts"].as_array().unwrap().len(), 3);
}
#[test]
fn a_tampered_artifact_is_detected() {
let fx = fixture();
fs::write(fx.bundle.join("images/server.tar"), b"EVIL image bytes").unwrap();
let err = verify(&fx.bundle, &fx.public_key).unwrap_err();
assert!(matches!(err, BundleError::ChecksumMismatch(p) if p == "images/server.tar"));
}
#[test]
fn a_tampered_checksum_list_fails_the_signature() {
let fx = fixture();
// Attacker edits both the artifact AND its checksum line — the
// signature over checksums.txt catches it.
let listing = fs::read_to_string(fx.bundle.join("checksums.txt")).unwrap();
fs::write(
fx.bundle.join("checksums.txt"),
listing.replace("images/server.tar", "images/sneaky.tar"),
)
.unwrap();
let err = verify(&fx.bundle, &fx.public_key).unwrap_err();
assert!(matches!(err, BundleError::BadSignature));
}
#[test]
fn the_wrong_public_key_is_refused() {
let fx = fixture();
let other_private = fx.root.join("other.key");
let other_public = fx.root.join("other.pub");
generate_keypair(&other_private, &other_public).unwrap();
let err = verify(&fx.bundle, &other_public).unwrap_err();
assert!(matches!(err, BundleError::BadSignature));
}
#[test]
fn a_missing_artifact_is_reported() {
let fx = fixture();
fs::remove_file(fx.bundle.join("migrations/0001_init.sql")).unwrap();
let err = verify(&fx.bundle, &fx.public_key).unwrap_err();
assert!(matches!(err, BundleError::Missing(p) if p.contains("0001_init.sql")));
}