Skip to content

Commit ba2fe8c

Browse files
authored
feat(a2a-redaction): wire wasmtime redactor host, signing CLI, and integration test scaffold (#394)
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent b8cc335 commit ba2fe8c

13 files changed

Lines changed: 1728 additions & 31 deletions

File tree

rsworkspace/Cargo.lock

Lines changed: 876 additions & 28 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rsworkspace/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ moka = { version = "=0.12.10", features = ["future"] }
9191
# Misc
9292
ed25519-dalek = { version = "=2.2.0", features = ["std", "rand_core"] }
9393
toml = "=0.8.23"
94+
wasmtime = "=45.0.0"
9495
http = "=1.4.0"
9596
reqwest = { version = "=0.12.28", default-features = false, features = ["json", "rustls-tls", "stream"] }
9697
sha2 = "=0.10.8"

rsworkspace/crates/a2a-redaction/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,25 @@ publish = false
1010
name = "a2a_redaction"
1111
path = "src/lib.rs"
1212

13+
[[bin]]
14+
name = "a2a-sign-bundle"
15+
path = "src/bin/sign-bundle.rs"
16+
1317
[lints]
1418
workspace = true
1519

1620
[dependencies]
1721
a2a = { workspace = true }
22+
clap = { workspace = true, features = ["derive"] }
1823
ed25519-dalek = { workspace = true }
1924
hex = { workspace = true }
2025
serde = { workspace = true, features = ["derive"] }
2126
serde_json = { workspace = true }
2227
sha2 = { workspace = true }
2328
thiserror = { workspace = true }
2429
toml = { workspace = true }
30+
tracing = { workspace = true }
31+
wasmtime = { workspace = true }
2532

2633
[dev-dependencies]
2734
tempfile = { workspace = true }
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
use std::fs;
2+
use std::path::{Path, PathBuf};
3+
4+
use clap::Parser;
5+
use ed25519_dalek::{Signer, SigningKey};
6+
7+
use a2a_redaction::signed_bundle::{
8+
Ed25519Signature, SIGNED_BUNDLE_VERSION, Sha256Digest, SignedBundleManifest, sign_bundle_digest,
9+
};
10+
use a2a_redaction::{SkillId, SkillIdError};
11+
12+
#[derive(Debug, Parser)]
13+
#[command(name = "a2a-sign-bundle", about = "Sign Tier-3 WASM policy bundles")]
14+
struct Args {
15+
/// Hex-encoded 32-byte ed25519 signing key seed (64 hex chars, no 0x prefix)
16+
#[arg(long)]
17+
key: String,
18+
19+
/// Directory containing `{skill}.wasm` and `{skill}.manifest.json` pairs
20+
#[arg(long)]
21+
skill_dir: PathBuf,
22+
}
23+
24+
#[derive(Debug, thiserror::Error)]
25+
enum CliError {
26+
#[error("signing key must not use 0x prefix")]
27+
KeyHasHexPrefix,
28+
#[error("invalid signing key hex: {0}")]
29+
KeyHexDecode(#[source] hex::FromHexError),
30+
#[error("signing key must be 32 bytes, got {0}")]
31+
KeyWrongLength(usize),
32+
#[error("read dir {path}: {source}", path = path.display())]
33+
ReadDir {
34+
path: PathBuf,
35+
#[source]
36+
source: std::io::Error,
37+
},
38+
#[error("read dir entry under {path}: {source}", path = path.display())]
39+
ReadDirEntry {
40+
path: PathBuf,
41+
#[source]
42+
source: std::io::Error,
43+
},
44+
#[error("invalid skill id derived from {path}: {source}", path = path.display())]
45+
InvalidSkillId {
46+
path: PathBuf,
47+
#[source]
48+
source: SkillIdError,
49+
},
50+
#[error("no *.wasm bundles found in {}", .0.display())]
51+
NoSkillBundles(PathBuf),
52+
#[error("read {path}: {source}", path = path.display())]
53+
ReadFile {
54+
path: PathBuf,
55+
#[source]
56+
source: std::io::Error,
57+
},
58+
#[error("serialize signature envelope for skill {skill}: {source}")]
59+
SerializeSignature {
60+
skill: String,
61+
#[source]
62+
source: serde_json::Error,
63+
},
64+
#[error("write {path}: {source}", path = path.display())]
65+
WriteFile {
66+
path: PathBuf,
67+
#[source]
68+
source: std::io::Error,
69+
},
70+
}
71+
72+
fn main() -> Result<(), CliError> {
73+
let args = Args::parse();
74+
let signing_key = parse_signing_key(&args.key)?;
75+
let skills = discover_skills(&args.skill_dir)?;
76+
if skills.is_empty() {
77+
return Err(CliError::NoSkillBundles(args.skill_dir));
78+
}
79+
80+
for skill in skills {
81+
sign_skill_bundle(&args.skill_dir, &skill, &signing_key)?;
82+
eprintln!("signed {}", skill.as_str());
83+
}
84+
85+
Ok(())
86+
}
87+
88+
fn parse_signing_key(raw: &str) -> Result<SigningKey, CliError> {
89+
let trimmed = raw.trim();
90+
if trimmed.starts_with("0x") || trimmed.starts_with("0X") {
91+
return Err(CliError::KeyHasHexPrefix);
92+
}
93+
let decoded = hex::decode(trimmed).map_err(CliError::KeyHexDecode)?;
94+
if decoded.len() != 32 {
95+
return Err(CliError::KeyWrongLength(decoded.len()));
96+
}
97+
let mut seed = [0u8; 32];
98+
seed.copy_from_slice(&decoded);
99+
Ok(SigningKey::from_bytes(&seed))
100+
}
101+
102+
fn discover_skills(dir: &Path) -> Result<Vec<SkillId>, CliError> {
103+
let mut skills = Vec::new();
104+
for entry in fs::read_dir(dir).map_err(|source| CliError::ReadDir {
105+
path: dir.to_path_buf(),
106+
source,
107+
})? {
108+
let entry = entry.map_err(|source| CliError::ReadDirEntry {
109+
path: dir.to_path_buf(),
110+
source,
111+
})?;
112+
let path = entry.path();
113+
if path.extension().and_then(|ext| ext.to_str()) != Some("wasm") {
114+
continue;
115+
}
116+
let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
117+
continue;
118+
};
119+
let skill = SkillId::new(stem).map_err(|source| CliError::InvalidSkillId {
120+
path: path.clone(),
121+
source,
122+
})?;
123+
skills.push(skill);
124+
}
125+
skills.sort();
126+
skills.dedup();
127+
Ok(skills)
128+
}
129+
130+
fn sign_skill_bundle(dir: &Path, skill: &SkillId, signing_key: &SigningKey) -> Result<(), CliError> {
131+
let wasm_path = dir.join(format!("{}.wasm", skill.as_str()));
132+
let manifest_path = dir.join(format!("{}.manifest.json", skill.as_str()));
133+
let wasm_bytes = fs::read(&wasm_path).map_err(|source| CliError::ReadFile {
134+
path: wasm_path.clone(),
135+
source,
136+
})?;
137+
let manifest_bytes = fs::read(&manifest_path).map_err(|source| CliError::ReadFile {
138+
path: manifest_path.clone(),
139+
source,
140+
})?;
141+
142+
let manifest_digest = Sha256Digest::hash(&manifest_bytes);
143+
let wasm_digest = Sha256Digest::hash(&wasm_bytes);
144+
let message = sign_bundle_digest(SIGNED_BUNDLE_VERSION, skill, manifest_digest, wasm_digest);
145+
let signature = Ed25519Signature::from_bytes(signing_key.sign(&message).to_bytes());
146+
let envelope = SignedBundleManifest::new(skill, manifest_digest, wasm_digest, signature);
147+
let sig_path = dir.join(format!("{}.sig", skill.as_str()));
148+
let sig_json = serde_json::to_vec_pretty(&envelope).map_err(|source| CliError::SerializeSignature {
149+
skill: skill.to_string(),
150+
source,
151+
})?;
152+
fs::write(&sig_path, sig_json).map_err(|source| CliError::WriteFile { path: sig_path, source })
153+
}

rsworkspace/crates/a2a-redaction/src/error.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,18 @@ pub enum RedactionError {
1717
/// boundary instead of being flattened to a String.
1818
#[error("json serialization for redaction failed: {0}")]
1919
Json(#[from] serde_json::Error),
20+
/// The Tier-3 wasm guest emitted the `A2A_T3_REFUSE` sentinel; the
21+
/// optional payload after the colon is the reason tag (e.g.
22+
/// `UnauthorizedDataCategory`). Surfacing this as a typed variant lets
23+
/// callers route refusals separately from generic JSON / wasm failures.
24+
#[error("tier-3 skill refused redaction{}", .0.as_ref().map(|tag| format!(": {tag}")).unwrap_or_default())]
25+
Tier3Refusal(Option<String>),
26+
/// Signed-bundle verification failed loading a skill (missing sig,
27+
/// malformed envelope, digest mismatch, ed25519 verify failure). The
28+
/// typed `SignatureVerificationError` preserves the *kind* of failure
29+
/// so callers can route on it instead of pattern-matching error text.
30+
#[error("signed bundle verification failed: {0}")]
31+
Signature(#[from] crate::signed_bundle::SignatureVerificationError),
2032
}
2133

2234
#[cfg(test)]

rsworkspace/crates/a2a-redaction/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ pub mod signed_bundle;
1313
pub mod skill_id;
1414
pub mod skill_manifest;
1515
pub mod tier3_sentinel;
16+
pub mod wasm;
1617
pub mod wasm_bundle_path;
1718

1819
pub use a2a_method::{A2aMethod, ParseA2aMethodError};
@@ -26,4 +27,5 @@ pub use skill_manifest::{
2627
SkillMethodMatcher, SkillSelectionPlan,
2728
};
2829
pub use tier3_sentinel::{TIER3_REFUSE_SENTINEL, output_is_tier3_refusal, tier3_refusal_reason_tag};
30+
pub use wasm::WasmRedactorHost;
2931
pub use wasm_bundle_path::WasmBundlePath;

rsworkspace/crates/a2a-redaction/src/redactor.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ pub trait Redactor {
1313
}
1414
}
1515

16-
#[allow(dead_code)]
1716
pub(crate) fn redact_message_parts_with(
1817
mut message: Message,
1918
mut transform_part_json: impl FnMut(&[u8]) -> Result<Vec<u8>, RedactionError>,
@@ -29,7 +28,6 @@ pub(crate) fn redact_message_parts_with(
2928
Ok(message)
3029
}
3130

32-
#[allow(dead_code)]
3331
pub(crate) fn redact_artifact_parts_with(
3432
mut artifact: Artifact,
3533
mut transform_part_json: impl FnMut(&[u8]) -> Result<Vec<u8>, RedactionError>,

rsworkspace/crates/a2a-redaction/src/signed_bundle/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ mod verify;
77

88
pub use digest::Sha256Digest;
99
pub use error::SignatureVerificationError;
10-
pub use manifest::SignedBundleManifest;
10+
pub use manifest::{SIGNED_BUNDLE_VERSION, SignedBundleManifest};
1111
pub use public_key::Ed25519PublicKey;
1212
pub use signature::Ed25519Signature;
1313
pub use verify::{sign_bundle_digest, verify_signed_bundle};
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
//! `redact_part(in_ptr,in_len):(out_ptr,out_len)` transports UTF-8 JSON through guest-exported linear `memory`.
2+
3+
use wasmtime::{Engine, Instance, Linker, Module, Store, StoreLimits, StoreLimitsBuilder};
4+
5+
use crate::error::RedactionError;
6+
7+
const SCRATCH_OFFSET: usize = 0x0800;
8+
const GUEST_PAGE_BYTES: usize = 65536;
9+
/// Cap on the length the guest can declare for its output buffer. Without a
10+
/// ceiling, a malicious or buggy module can return a near-`i32::MAX` length
11+
/// and force the host to allocate gigabytes (or OOM) before we'd even hit
12+
/// the linear-memory read. We bound it to the single-page payload window
13+
/// the guest is allowed to write into in the first place.
14+
const MAX_GUEST_OUTPUT_BYTES: usize = GUEST_PAGE_BYTES;
15+
/// Fuel budget for a single `redact_part` call. Wasmtime decrements this per
16+
/// instruction executed; a guest that loops indefinitely traps with
17+
/// `OutOfFuel` instead of blocking the caller thread. The value is sized
18+
/// for the canonical per-part redact workload (one JSON part, scan-and-
19+
/// replace); the gateway can lift the cap if a skill genuinely needs more.
20+
const GUEST_FUEL_PER_CALL: u64 = 10_000_000;
21+
/// Hard cap on a single store's linear-memory growth. The guest already
22+
/// only writes into one page worth of scratch, but a buggy module could
23+
/// allocate more pages internally; bound that to keep one bad guest from
24+
/// pinning the host's RAM.
25+
const MAX_STORE_MEMORY_BYTES: usize = 16 * 1024 * 1024;
26+
27+
pub(crate) fn new_engine() -> Result<Engine, RedactionError> {
28+
let mut config = wasmtime::Config::default();
29+
config.wasm_multi_value(true);
30+
// Bound guest CPU time deterministically. Without fuel, a redact_part
31+
// guest stuck in an infinite loop would block the synchronous Redactor
32+
// trait call indefinitely.
33+
config.consume_fuel(true);
34+
Engine::new(&config).map_err(|e| RedactionError::WasmEngine(e.to_string()))
35+
}
36+
37+
/// Per-call store state — wraps StoreLimits so wasmtime can enforce the
38+
/// memory ceiling on `memory.grow` without the host having to check after
39+
/// the fact.
40+
struct StoreState {
41+
limits: StoreLimits,
42+
}
43+
44+
pub(crate) fn redact_part_guest(engine: &Engine, module: &Module, payload: &[u8]) -> Result<Vec<u8>, RedactionError> {
45+
if SCRATCH_OFFSET.saturating_add(payload.len()) > GUEST_PAGE_BYTES {
46+
return Err(RedactionError::WasmMemory(
47+
"redaction payload does not fit in one wasm guest page".into(),
48+
));
49+
}
50+
51+
let in_len = i32::try_from(payload.len())
52+
.map_err(|_| RedactionError::WasmMemory("payload length does not fit in wasm i32 bounds".into()))?;
53+
54+
let mut store = Store::new(
55+
engine,
56+
StoreState {
57+
limits: StoreLimitsBuilder::new().memory_size(MAX_STORE_MEMORY_BYTES).build(),
58+
},
59+
);
60+
store.limiter(|state| &mut state.limits);
61+
store
62+
.set_fuel(GUEST_FUEL_PER_CALL)
63+
.map_err(|e| RedactionError::WasmEngine(format!("set_fuel: {e}")))?;
64+
let linker: Linker<StoreState> = Linker::new(engine);
65+
let instance: Instance = linker
66+
.instantiate(&mut store, module)
67+
.map_err(|e| RedactionError::WasmInstance(e.to_string()))?;
68+
69+
let memory = instance
70+
.get_memory(&mut store, "memory")
71+
.ok_or_else(|| RedactionError::WasmAbi("wasm module must export linear memory named memory".into()))?;
72+
73+
memory
74+
.write(&mut store, SCRATCH_OFFSET, payload)
75+
.map_err(|e| RedactionError::WasmMemory(e.to_string()))?;
76+
77+
let redact = instance
78+
.get_typed_func::<(i32, i32), (i32, i32)>(&mut store, "redact_part")
79+
.map_err(|_| {
80+
RedactionError::WasmAbi("wasm module must export redact_part with type (i32,i32)->(i32,i32)".into())
81+
})?;
82+
83+
let (out_base, out_len) = redact
84+
.call(&mut store, (SCRATCH_OFFSET as i32, in_len))
85+
.map_err(|e| RedactionError::WasmCall(e.to_string()))?;
86+
87+
let out_base = usize::try_from(out_base)
88+
.map_err(|_| RedactionError::WasmAbi("wasm redact_part returned negative output pointer".into()))?;
89+
let out_len = usize::try_from(out_len)
90+
.map_err(|_| RedactionError::WasmAbi("wasm redact_part returned negative output length".into()))?;
91+
92+
if out_len > MAX_GUEST_OUTPUT_BYTES {
93+
return Err(RedactionError::WasmAbi(format!(
94+
"wasm redact_part output length {out_len} exceeds guest cap {MAX_GUEST_OUTPUT_BYTES}"
95+
)));
96+
}
97+
let memory_size = memory.data_size(&store);
98+
if out_base.saturating_add(out_len) > memory_size {
99+
return Err(RedactionError::WasmAbi(format!(
100+
"wasm redact_part output [base={out_base}, len={out_len}) exceeds linear memory size {memory_size}"
101+
)));
102+
}
103+
104+
let mut dst = vec![0u8; out_len];
105+
memory
106+
.read(&store, out_base, &mut dst)
107+
.map_err(|e| RedactionError::WasmMemory(e.to_string()))?;
108+
109+
Ok(dst)
110+
}
111+
112+
#[cfg(test)]
113+
mod tests {
114+
use super::*;
115+
use wasmtime::Module;
116+
117+
#[test]
118+
fn builds_default_compatible_engine() {
119+
let engine = new_engine().expect("wasmtime engine");
120+
drop(engine);
121+
}
122+
123+
#[test]
124+
fn identity_guest_round_trips_utf8_payload() {
125+
let engine = new_engine().unwrap();
126+
let wasm = include_bytes!(concat!(
127+
env!("CARGO_MANIFEST_DIR"),
128+
"/tests/fixtures/identity_redact_part.wasm"
129+
));
130+
let module = Module::from_binary(&engine, wasm).unwrap();
131+
let inp = br#"{"k":42}"#.to_vec();
132+
let got = redact_part_guest(&engine, &module, &inp).unwrap();
133+
assert_eq!(got, inp);
134+
}
135+
}

0 commit comments

Comments
 (0)