use crate::ops;
use ic_json::{parse, Json};
use std::io::{BufRead, Read, Write};
const PROTOCOL_VERSION: &str = "2024-11-05";
struct Tool {
name: &'static str,
description: &'static str,
schema: fn() -> Json,
call: fn(&Json) -> Result<Json, String>,
}
fn string_prop(desc: &str) -> Json {
Json::object([
("type", Json::str("string")),
("description", Json::str(desc)),
])
}
fn bool_prop(desc: &str) -> Json {
Json::object([
("type", Json::str("boolean")),
("description", Json::str(desc)),
])
}
fn integer_prop(desc: &str) -> Json {
Json::object([
("type", Json::str("integer")),
("description", Json::str(desc)),
])
}
fn enum_prop(desc: &str, values: &str) -> Json {
Json::object([
("type", Json::str("string")),
("description", Json::str(desc)),
(
"enum",
Json::Array(values.split(", ").map(Json::str).collect()),
),
])
}
fn schema(props: Vec<(&str, Json)>, required: &[&str]) -> Json {
let mut map = std::collections::BTreeMap::new();
for (k, v) in props {
map.insert(k.to_string(), v);
}
Json::object([
("type", Json::str("object")),
("properties", Json::Object(map)),
(
"required",
Json::Array(required.iter().map(|r| Json::str(*r)).collect()),
),
])
}
fn arg<'a>(args: &'a Json, name: &str) -> Option<&'a str> {
args.get(name).and_then(|v| v.as_str())
}
fn flag(args: &Json, name: &str) -> bool {
args.get(name).and_then(|v| v.as_bool()).unwrap_or(false)
}
fn required<'a>(args: &'a Json, name: &str) -> Result<&'a str, String> {
arg(args, name).ok_or_else(|| format!("missing required argument '{name}'"))
}
fn hex_arg(args: &Json, name: &str) -> Result<Vec<u8>, String> {
let text = required(args, name)?;
ic_core::codec::unhex(text).map_err(|e| format!("'{name}' must be hex: {e}"))
}
fn optional_hex(args: &Json, name: &str) -> Result<Vec<u8>, String> {
match arg(args, name) {
Some(text) if !text.is_empty() => {
ic_core::codec::unhex(text).map_err(|e| format!("'{name}' must be hex: {e}"))
}
_ => Ok(Vec::new()),
}
}
fn message_arg(args: &Json, text_name: &str, hex_name: &str) -> Result<Vec<u8>, String> {
match (arg(args, text_name), arg(args, hex_name)) {
(Some(t), None) => Ok(t.as_bytes().to_vec()),
(None, Some(h)) => {
ic_core::codec::unhex(h).map_err(|e| format!("'{hex_name}' must be hex: {e}"))
}
_ => Err(format!("give exactly one of '{text_name}' or '{hex_name}'")),
}
}
fn instructions() -> String {
format!(
"Call crypto_recommend before choosing an algorithm. It reports the correct choice for \
your constraints, and says plainly when the correct choice is not implemented here \
rather than offering a substitute. Use ontology_show to read parameter bounds and usage \
constraints before writing a call. IronCrypto is not CMVP validated.\n\n\
Rules that hold for every algorithm (crypto_rules has the reasons):\n{}",
ic_ontology::RULES
.iter()
.map(|r| format!("- {} Instead: {}", r.rule, r.instead))
.collect::<Vec<_>>()
.join("\n")
)
}
fn tools() -> Vec<Tool> {
vec![
Tool {
name: "crypto_recommend",
description:
"Choose a cryptographic algorithm for a task. Returns the recommendation, the \
reasoning, rejected alternatives with reasons, the exact Rust path to call, and \
the constraints that must be honoured. If the correct algorithm is not \
implemented here, says so instead of substituting a different one.",
schema: || {
schema(
vec![
("intent", enum_prop("What you are trying to do.", &ops::intent_list())),
("fips", bool_prop("Require FIPS-approved algorithms only.")),
("post_quantum", bool_prop("Require resistance to a quantum adversary.")),
("aes_hardware", bool_prop("The target has AES hardware acceleration.")),
],
&["intent"],
)
},
call: |args| {
ops::recommend_json(
required(args, "intent")?,
flag(args, "fips"),
flag(args, "post_quantum"),
flag(args, "aes_hardware"),
)
},
},
Tool {
name: "key_inspect",
description:
"Identify a cryptographic key. Give it the contents of a PEM or DER key file and \
it reports the algorithm, whether the key is public or private, the size, and \
the ontology entry to look up next. It parses structure only — no private \
material is used and nothing is signed or decrypted — so it is safe to run on \
an unknown file.",
schema: || {
schema(
vec![(
"key",
string_prop(
"The key file's contents. PEM text, or DER as a hex string.",
),
)],
&["key"],
)
},
call: |args| {
let text = required(args, "key")?;
let bytes = if text.contains("-----BEGIN ") {
text.as_bytes().to_vec()
} else {
let trimmed: String =
text.chars().filter(|c| !c.is_ascii_whitespace()).collect();
let mut out = vec![0u8; trimmed.len() / 2];
ic_core::codec::hex_decode(trimmed.as_bytes(), &mut out)
.map_err(|_| "key must be PEM text or a hex-encoded DER file".to_string())?;
out
};
ops::key_json(&bytes)
},
},
Tool {
name: "ontology_list",
description:
"List algorithms, optionally filtered by class, purpose, FIPS approval, and \
whether they are implemented in this build.",
schema: || {
schema(
vec![
("class", enum_prop("Kind of algorithm.", &ops::class_list())),
("purpose", enum_prop("Security goal it must serve.", &ops::purpose_list())),
("fips_only", bool_prop("Only algorithms usable in FIPS approved mode.")),
("available_only", bool_prop("Only algorithms implemented in this build.")),
],
&[],
)
},
call: |args| {
let entries = ops::list(
arg(args, "class"),
arg(args, "purpose"),
flag(args, "fips_only"),
flag(args, "available_only"),
)?;
Ok(Json::object([
("count", Json::num(entries.len() as f64)),
(
"algorithms",
Json::Array(
entries
.iter()
.map(|e| {
Json::object([
("id", Json::str(e.id)),
("name", Json::str(e.name)),
("class", Json::str(e.class.id())),
("summary", Json::str(e.summary)),
("fipsStatus", Json::str(e.fips.id())),
("implementationStatus", Json::str(e.status.id())),
])
})
.collect(),
),
),
]))
},
},
Tool {
name: "ontology_show",
description:
"Full ontology record for one algorithm: strength, FIPS standing, parameter \
bounds, usage constraints with severities, related algorithms, Rust path, and a \
usage example. Accepts ids, names, and aliases.",
schema: || {
schema(
vec![("algorithm", string_prop("Algorithm id, name, or alias."))],
&["algorithm"],
)
},
call: |args| {
let name = required(args, "algorithm")?;
let e = ic_ontology::get(name)
.ok_or_else(|| format!("unknown algorithm '{name}'"))?;
Ok(ops::entry_detail_json(e))
},
},
Tool {
name: "crypto_rules",
description:
"The rules that hold whichever algorithm is chosen -- nonce reuse, tag \
comparison, password hashing, key material -- each with why and what to call \
instead. Read them before writing code against this library.",
schema: || schema(vec![], &[]),
call: |_| Ok(ops::rules_json()),
},
Tool {
name: "crypto_lint",
description:
"Check Rust source that uses this library for the known misuses: literal \
nonces and keys, tags compared with ==, passwords through a plain hash or \
weak PBKDF2, raw OS randomness, raw shared secrets as keys, CBC or CTR \
without a MAC. Each finding names its rule. It matches text a line at a \
time: a finding can be wrong, and no findings is not proof of correctness.",
schema: || {
schema(
vec![
("source", string_prop("The Rust source to check.")),
("path", string_prop("Optional name to report findings under.")),
(
"includeTests",
bool_prop("Check #[cfg(test)] code too; skipped by default."),
),
],
&["source"],
)
},
call: |args| {
let name = arg(args, "path").unwrap_or("source").to_string();
let findings: Vec<_> =
crate::lint::lint_with(required(args, "source")?, flag(args, "includeTests"))
.into_iter()
.map(|f| (name.clone(), f))
.collect();
Ok(crate::lint::report_json(&findings))
},
},
Tool {
name: "ontology_errors",
description:
"The library's complete error vocabulary, with what each failure means, how to \
recover, and whether retrying could help.",
schema: || schema(vec![], &[]),
call: |_| Ok(ops::errors_json()),
},
Tool {
name: "crypto_controls",
description:
"Security framework coverage: MITRE CWE weakness classes, MITRE ATT&CK \
techniques, and CMMC 2.0 practices, each with whether IronCrypto satisfies it \
and the file that evidences it. Filter by framework, algorithm or compliance \
state. IMPORTANT: IronCrypto is NOT FIPS-validated, so CMMC SC.L2-3.13.11 is \
reported as unmet; any answer about FIPS-validated cryptography must say so \
rather than inferring satisfaction from the other entries.",
schema: || {
schema(
vec![
(
"framework",
enum_prop("Narrow to one framework.", "cwe, attack, cmmc"),
),
(
"algorithm",
string_prop(
"Narrow to controls bearing on this algorithm id. \
Library-wide controls always match.",
),
),
(
"state",
enum_prop(
"Narrow to one compliance state.",
"met, partial, unmet, not-applicable",
),
),
(
"control",
string_prop("A single control id, e.g. CWE-327 or SC.L2-3.13.11."),
),
],
&[],
)
},
call: |args| match arg(args, "control") {
Some(id) => ops::control_lookup_json(id),
None => ops::controls_json(
arg(args, "framework"),
arg(args, "algorithm"),
arg(args, "state"),
),
},
},
Tool {
name: "crypto_standard",
description:
"Look up the standards that define an algorithm, or one document by its citation. \
Returns the title, publisher, year, whether it is still current, what it covers, \
and the obligations it imposes on an implementation -- each with whether this \
library meets it and the file that evidences it. Use this to answer 'what does \
FIPS 203 require here' without guessing.",
schema: || {
schema(
vec![
(
"standard",
string_prop("A citation such as 'FIPS 203' or 'RFC 8439'."),
),
(
"algorithm",
string_prop(
"An algorithm id; returns every document that defines it.",
),
),
],
&[],
)
},
call: |args| match arg(args, "standard") {
Some(id) => ops::standard_lookup_json(id),
None => ops::standards_json(arg(args, "algorithm")),
},
},
Tool {
name: "crypto_requirements",
description:
"The conformance view: every normative obligation drawn from the standards, with \
whether this library meets it, meets it partially, does not, or is not bound by it \
-- and why. A partial answer names the gap, which is the one a binary yes/no would \
misreport in either direction. Filter by state or algorithm. Nothing here asserts \
FIPS validation; a met requirement means the code does what the document asks, not \
that a laboratory has agreed.",
schema: || {
schema(
vec![
(
"state",
enum_prop(
"Narrow to one compliance state.",
"met, partial, unmet, not-applicable",
),
),
(
"algorithm",
string_prop(
"Narrow to obligations bearing on this algorithm id. Library-wide \
obligations always match.",
),
),
],
&[],
)
},
call: |args| ops::requirements_json(arg(args, "state"), arg(args, "algorithm")),
},
Tool {
name: "crypto_capabilities",
description:
"What this build can and cannot do: backend, module state, algorithm counts, and \
an explicit statement of FIPS validation status.",
schema: || schema(vec![], &[]),
call: |_| Ok(ops::capabilities_json()),
},
Tool {
name: "crypto_selftest",
description:
"Run the FIPS known-answer tests, for one algorithm or for all of them.",
schema: || {
schema(
vec![("algorithm", string_prop("Optional: test just this algorithm."))],
&[],
)
},
call: |args| ops::selftest_json(arg(args, "algorithm")),
},
Tool {
name: "crypto_digest",
description: "Hash a UTF-8 string and return the digest as hex.",
schema: || {
schema(
vec![
("algorithm", string_prop("Digest id, e.g. sha2-256.")),
("data", string_prop("The text to hash.")),
],
&["algorithm", "data"],
)
},
call: |args| {
let hex = ops::digest_hex(
required(args, "algorithm")?,
required(args, "data")?.as_bytes(),
)?;
Ok(Json::object([("digest", Json::str(hex))]))
},
},
Tool {
name: "crypto_hmac",
description: "Compute an HMAC tag over a UTF-8 string with a hex key.",
schema: || {
schema(
vec![
("algorithm", string_prop("MAC id, e.g. hmac-sha2-256.")),
("key", string_prop("Hex-encoded key.")),
("data", string_prop("The text to authenticate.")),
],
&["algorithm", "key", "data"],
)
},
call: |args| {
let key = hex_arg(args, "key")?;
let hex = ops::hmac_hex(
required(args, "algorithm")?,
&key,
required(args, "data")?.as_bytes(),
)?;
Ok(Json::object([("tag", Json::str(hex))]))
},
},
Tool {
name: "crypto_seal",
description:
"Encrypt with an AEAD and return the ciphertext, tag and nonce as hex. Key and \
associated data are hex; the plaintext is a UTF-8 string. Omit the nonce and a \
fresh random 96-bit one is drawn and returned: do that unless a protocol fixes \
the nonce. A (key, nonce) pair this server has already sealed under is refused.",
schema: || {
schema(
vec![
("algorithm", string_prop("AEAD id, e.g. aes-256-gcm.")),
("key", string_prop("Hex-encoded key.")),
(
"nonce",
string_prop(
"Optional hex nonce. Omit it to have a random one drawn; a \
supplied one must never have been used under this key.",
),
),
("aad", string_prop("Optional hex-encoded associated data.")),
("plaintext", string_prop("The text to encrypt.")),
],
&["algorithm", "key", "plaintext"],
)
},
call: |args| {
let key = ic_core::Zeroizing::new(hex_arg(args, "key")?);
let nonce = match arg(args, "nonce") {
Some(text) if !text.is_empty() => ic_core::codec::unhex(text)
.map_err(|e| format!("'nonce' must be hex: {e}"))?,
_ => ops::random_nonce()?.to_vec(),
};
ops::claim_nonce(&key, &nonce)?;
let aad = match arg(args, "aad") {
Some(text) if !text.is_empty() => ic_core::codec::unhex(text)
.map_err(|e| format!("'aad' must be hex: {e}"))?,
_ => Vec::new(),
};
let (ct, tag) = ops::seal_hex(
required(args, "algorithm")?,
&key,
&nonce,
&aad,
required(args, "plaintext")?.as_bytes(),
)?;
Ok(Json::object([
("ciphertext", Json::str(ct)),
("tag", Json::str(tag)),
("nonce", Json::str(ic_core::codec::hex(&nonce))),
]))
},
},
Tool {
name: "crypto_open",
description:
"Verify and decrypt an AEAD ciphertext, the inverse of crypto_seal. Returns the \
plaintext as hex, and as text when it is UTF-8. A wrong key, nonce, \
associated data or tag is one error, 'authentication failed', and no \
plaintext.",
schema: || {
schema(
vec![
("algorithm", string_prop("AEAD id, e.g. aes-256-gcm.")),
("key", string_prop("Hex-encoded key.")),
("nonce", string_prop("Hex nonce, as crypto_seal returned it.")),
("aad", string_prop("Optional hex associated data, as sealed.")),
("ciphertext", string_prop("Hex ciphertext.")),
("tag", string_prop("Hex authentication tag.")),
],
&["algorithm", "key", "nonce", "ciphertext", "tag"],
)
},
call: |args| {
let key = ic_core::Zeroizing::new(hex_arg(args, "key")?);
let plaintext = ops::open_bytes(
required(args, "algorithm")?,
&key,
&hex_arg(args, "nonce")?,
&optional_hex(args, "aad")?,
&hex_arg(args, "ciphertext")?,
&hex_arg(args, "tag")?,
)?;
let hex = Json::str(ic_core::codec::hex(&plaintext));
Ok(match core::str::from_utf8(&plaintext) {
Ok(text) => Json::object([("plaintextHex", hex), ("plaintext", Json::str(text))]),
Err(_) => Json::object([("plaintextHex", hex)]),
})
},
},
Tool {
name: "crypto_verify",
description:
"Check a signature. Returns {valid: true|false}; an invalid signature is an \
answer, not an error. Errors mean the question could not be asked: an unknown \
algorithm, or a key or signature of the wrong shape. Public keys are raw \
(uncompressed SEC1 point for ECDSA, 32 bytes for Ed25519, FIPS 204 encoding \
for ML-DSA), except RSA, which takes DER: a SubjectPublicKeyInfo or a PKCS#1 \
RSAPublicKey.",
schema: || {
schema(
vec![
("algorithm", string_prop("Signature id, e.g. ecdsa-p256-sha256, ed25519, ml-dsa-65, rsa-pss-sha256.")),
("publicKey", string_prop("Hex public key.")),
("message", string_prop("The signed message as UTF-8 text; or use messageHex.")),
("messageHex", string_prop("The signed message as hex; or use message.")),
("signature", string_prop("Hex signature.")),
("context", string_prop("Optional hex ML-DSA context string.")),
],
&["algorithm", "publicKey", "signature"],
)
},
call: |args| {
let valid = ops::verify_signature(
required(args, "algorithm")?,
&hex_arg(args, "publicKey")?,
&message_arg(args, "message", "messageHex")?,
&hex_arg(args, "signature")?,
&optional_hex(args, "context")?,
)?;
Ok(Json::object([("valid", Json::Bool(valid))]))
},
},
Tool {
name: "crypto_derive",
description:
"Derive key material with HKDF (RFC 5869) and return it as hex. Use it to turn \
a shared secret into a key: put both parties' public keys in info. The output \
is secret; treat it as a key.",
schema: || {
schema(
vec![
("algorithm", string_prop("hkdf-sha2-256, hkdf-sha2-384 or hkdf-sha2-512.")),
("ikm", string_prop("Hex input keying material, e.g. a shared secret.")),
("salt", string_prop("Optional hex salt.")),
("info", string_prop("Optional hex context, binding the key to its use.")),
("length", integer_prop("Output bytes, at most 255 hash lengths.")),
],
&["algorithm", "ikm", "length"],
)
},
call: |args| {
let length = args
.get("length")
.and_then(|v| v.as_i64())
.filter(|&n| (1..=16320).contains(&n))
.ok_or("'length' must be an integer from 1 to 16320")?;
let ikm = ic_core::Zeroizing::new(hex_arg(args, "ikm")?);
let okm = ops::hkdf_bytes(
required(args, "algorithm")?,
&ikm,
&optional_hex(args, "salt")?,
&optional_hex(args, "info")?,
length as usize,
)?;
Ok(Json::object([("okm", Json::str(ic_core::codec::hex(&okm)))]))
},
},
Tool {
name: "crypto_random",
description:
"Generate random bytes from the OS-seeded SP 800-90A DRBG, returned as hex.",
schema: || {
schema(
vec![(
"bytes",
Json::object([
("type", Json::str("integer")),
("minimum", Json::num(1)),
("maximum", Json::num(1024)),
("description", Json::str("How many bytes to generate.")),
]),
)],
&["bytes"],
)
},
call: |args| {
let n = args
.get("bytes")
.and_then(|v| v.as_i64())
.ok_or("missing required argument 'bytes'")?;
let hex = ops::random_hex(n.max(0) as usize)?;
Ok(Json::object([("hex", Json::str(hex))]))
},
},
]
}
fn error_response(id: Json, code: i64, message: &str) -> Json {
Json::object([
("jsonrpc", Json::str("2.0")),
("id", id),
(
"error",
Json::object([
("code", Json::num(code as f64)),
("message", Json::str(message)),
]),
),
])
}
fn result_response(id: Json, result: Json) -> Json {
Json::object([
("jsonrpc", Json::str("2.0")),
("id", id),
("result", result),
])
}
fn tool_content(body: Json, is_error: bool) -> Json {
Json::object([
(
"content",
Json::Array(vec![Json::object([
("type", Json::str("text")),
("text", Json::str(body.to_string())),
])]),
),
("isError", Json::Bool(is_error)),
])
}
pub fn handle(request: &Json) -> Option<Json> {
let method = request.get("method").and_then(|m| m.as_str()).unwrap_or("");
let id = request.get("id").cloned();
let params = request.get("params").cloned().unwrap_or(Json::Null);
let id = id?;
let response = match method {
"initialize" => result_response(
id,
Json::object([
("protocolVersion", Json::str(PROTOCOL_VERSION)),
("capabilities", Json::object([("tools", Json::object([]))])),
(
"serverInfo",
Json::object([
("name", Json::str("ironcrypto")),
("version", Json::str(ironcrypto::VERSION)),
]),
),
("instructions", Json::str(instructions())),
]),
),
"tools/list" => result_response(
id,
Json::object([(
"tools",
Json::Array(
tools()
.iter()
.map(|t| {
Json::object([
("name", Json::str(t.name)),
("description", Json::str(t.description)),
("inputSchema", (t.schema)()),
])
})
.collect(),
),
)]),
),
"tools/call" => {
let name = params.get("name").and_then(|n| n.as_str()).unwrap_or("");
let args = params
.get("arguments")
.cloned()
.unwrap_or(Json::Object(Default::default()));
match tools().iter().find(|t| t.name == name) {
Some(tool) => match (tool.call)(&args) {
Ok(body) => result_response(id, tool_content(body, false)),
Err(message) => result_response(
id,
tool_content(Json::object([("error", Json::str(message))]), true),
),
},
None => error_response(id, -32601, &format!("unknown tool '{name}'")),
}
}
"ping" => result_response(id, Json::object([])),
other => error_response(id, -32601, &format!("unknown method '{other}'")),
};
Some(response)
}
const MAX_MESSAGE: usize = 16 * 1024 * 1024;
pub fn serve() -> std::io::Result<()> {
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
serve_on(stdin.lock(), &mut stdout)
}
pub fn serve_on(mut input: impl BufRead, stdout: &mut impl Write) -> std::io::Result<()> {
loop {
let mut line = Vec::new();
let read = (&mut input)
.take(MAX_MESSAGE as u64 + 1)
.read_until(b'\n', &mut line)?;
if read == 0 {
break;
}
if line.len() > MAX_MESSAGE {
let mut sink = Vec::new();
while !line.ends_with(b"\n") {
sink.clear();
let n = (&mut input).take(65536).read_until(b'\n', &mut sink)?;
if n == 0 || sink.ends_with(b"\n") {
break;
}
}
let response = error_response(
Json::Null,
-32700,
&format!("message larger than {MAX_MESSAGE} bytes"),
);
writeln!(stdout, "{response}")?;
stdout.flush()?;
continue;
}
let line = String::from_utf8_lossy(&line);
if line.trim().is_empty() {
continue;
}
let response = match parse(&line) {
Ok(request) => handle(&request),
Err(message) => Some(error_response(
Json::Null,
-32700,
&format!("parse error: {message}"),
)),
};
if let Some(response) = response {
writeln!(stdout, "{response}")?;
stdout.flush()?;
}
}
Ok(())
}
#[cfg(test)]
mod totality_tests {
use super::*;
fn envelope(method: &str, params: Json) -> Json {
Json::object([
("jsonrpc", Json::str("2.0")),
("id", Json::num(1)),
("method", Json::str(method)),
("params", params),
])
}
fn hostile_strings() -> Vec<String> {
let mut out = vec![
String::new(),
"0".into(),
"zz".into(), "0z".into(), "00".repeat(1000), "ff".repeat(1000),
"\u{0}\u{1}\u{7f}".into(), "\u{1f600}".into(), "-----BEGIN PUBLIC KEY-----".into(), "-".repeat(500),
"a".repeat(100_000),
];
for n in [1usize, 15, 16, 17, 31, 32, 33, 47, 48, 63, 64, 65] {
out.push("a".repeat(n));
out.push("0".repeat(n));
}
out
}
#[test]
fn every_tool_returns_on_hostile_arguments() {
let tools = tools();
assert!(tools.len() >= 10, "only {} tools found", tools.len());
let mut calls = 0;
let mut reached_the_tool = 0;
for tool in &tools {
let schema = (tool.schema)();
let Some(Json::Object(props)) = schema.get("properties") else {
panic!("{} has no properties", tool.name)
};
let names: Vec<String> = props.keys().cloned().collect();
for value in hostile_strings() {
let mut args = std::collections::BTreeMap::new();
for name in &names {
args.insert(name.clone(), Json::str(value.clone()));
}
let call = envelope(
"tools/call",
Json::object([
("name", Json::str(tool.name)),
("arguments", Json::Object(args.clone())),
]),
);
let response = handle(&call).expect("a request with an id gets a response");
calls += 1;
let text = response.to_string();
let objected = text.contains("\"isError\":true");
if objected && !text.contains("missing required argument") {
reached_the_tool += 1;
}
}
for dropped in &names {
let mut short = std::collections::BTreeMap::new();
for name in &names {
if name != dropped {
short.insert(name.clone(), Json::str("00"));
}
}
let call = envelope(
"tools/call",
Json::object([
("name", Json::str(tool.name)),
("arguments", Json::Object(short)),
]),
);
assert!(handle(&call).is_some(), "{} without {dropped}", tool.name);
calls += 1;
}
for wrong in [
Json::Null,
Json::Bool(true),
Json::Number(-1.0),
Json::Number(f64::MAX),
Json::Array(vec![Json::Null; 3]),
] {
let mut args = std::collections::BTreeMap::new();
for name in &names {
args.insert(name.clone(), wrong.clone());
}
let call = envelope(
"tools/call",
Json::object([
("name", Json::str(tool.name)),
("arguments", Json::Object(args)),
]),
);
assert!(handle(&call).is_some(), "{} on {wrong:?}", tool.name);
calls += 1;
}
}
assert!(calls > 250, "only {calls} calls made");
assert!(
reached_the_tool > calls / 4,
"only {reached_the_tool} of {calls} calls reached a tool that then \
objected to the value it was given; the rest either stopped at the \
argument check or succeeded without reading anything, so the hostile \
values never got near the code that interprets them"
);
}
#[test]
fn the_protocol_layer_returns_on_anything() {
let shapes = [
Json::Null,
Json::Bool(false),
Json::Number(0.0),
Json::str("not an object"),
Json::Array(vec![]),
Json::object([]),
Json::object([("method", Json::Null)]),
Json::object([("id", Json::Null), ("method", Json::Number(7.0))]),
Json::object([("id", Json::str("x")), ("method", Json::str("tools/call"))]),
Json::object([
("id", Json::Number(1.0)),
("method", Json::str("tools/call")),
("params", Json::str("not an object")),
]),
Json::object([
("id", Json::Number(1.0)),
("method", Json::str("tools/call")),
("params", Json::object([("name", Json::Number(3.0))])),
]),
];
let mut tried = 0;
for shape in shapes {
let _ = handle(&shape);
tried += 1;
}
assert_eq!(tried, 11);
}
}
#[cfg(test)]
mod serve_tests {
use super::*;
#[test]
fn an_oversized_message_is_refused_and_the_next_one_still_answered() {
let huge = "x".repeat(MAX_MESSAGE + 1024);
let input = format!(
concat!(
r#"{{"jsonrpc":"2.0","id":1,"method":"tools/list"}}"#,
"\n",
r#"{{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{{"name":"crypto_digest","arguments":{{"algorithm":"sha2-256","data":"{}"}}}}}}"#,
"\n",
r#"{{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{{"name":"crypto_random","arguments":{{"bytes":4}}}}}}"#,
"\n"
),
huge
);
let mut out = Vec::new();
serve_on(input.as_bytes(), &mut out).expect("serving should not fail");
let out = String::from_utf8(out).expect("responses are utf-8");
let responses: Vec<&str> = out.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(
responses.len(),
3,
"one response per message, including the refusal: {responses:#?}"
);
assert!(responses[0].contains("\"tools\""), "{}", responses[0]);
assert!(
responses[1].contains("-32700") && responses[1].contains("larger than"),
"the oversized message should be refused with a reason: {}",
responses[1]
);
assert!(
responses[2].contains("hex") && responses[2].contains("\"id\":3"),
"the message after the oversized one must still be served: {}",
responses[2]
);
}
#[test]
fn a_message_within_the_bound_is_served() {
let data = "ab".repeat(100_000);
let input = format!(
r#"{{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{{"name":"crypto_digest","arguments":{{"algorithm":"sha2-256","data":"{data}"}}}}}}"#
) + "\n";
assert!(input.len() < MAX_MESSAGE);
let mut out = Vec::new();
serve_on(input.as_bytes(), &mut out).unwrap();
let out = String::from_utf8(out).unwrap();
assert!(
out.contains("digest") && !out.contains("larger than"),
"a large but permitted message should be served: {out}"
);
}
#[test]
fn empty_input_terminates() {
let mut out = Vec::new();
serve_on(&b""[..], &mut out).unwrap();
assert!(out.is_empty());
let one = br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#;
let mut out = Vec::new();
serve_on(&one[..], &mut out).unwrap();
assert!(String::from_utf8_lossy(&out).contains("tools"));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn request(method: &str, params: Json) -> Json {
Json::object([
("jsonrpc", Json::str("2.0")),
("id", Json::num(1)),
("method", Json::str(method)),
("params", params),
])
}
fn call(name: &str, args: Json) -> Json {
let req = request(
"tools/call",
Json::object([("name", Json::str(name)), ("arguments", args)]),
);
handle(&req).expect("a request with an id must be answered")
}
fn body(response: &Json) -> Json {
let content = response.get("result").unwrap().get("content").unwrap();
match content {
Json::Array(items) => parse(items[0].get("text").unwrap().as_str().unwrap()).unwrap(),
_ => panic!("expected content array"),
}
}
fn is_error(response: &Json) -> bool {
response
.get("result")
.and_then(|r| r.get("isError"))
.and_then(|b| b.as_bool())
.unwrap_or(false)
}
#[test]
fn initialize_reports_protocol_and_server_info() {
let r = handle(&request("initialize", Json::Null)).unwrap();
let result = r.get("result").unwrap();
assert_eq!(
result.get("protocolVersion").unwrap().as_str(),
Some(PROTOCOL_VERSION)
);
assert_eq!(
result
.get("serverInfo")
.unwrap()
.get("name")
.unwrap()
.as_str(),
Some("ironcrypto")
);
let instructions = result.get("instructions").unwrap().as_str().unwrap();
for r in ic_ontology::RULES {
assert!(instructions.contains(r.rule), "{} missing", r.id);
}
}
#[test]
fn tools_list_is_complete_and_well_formed() {
let r = handle(&request("tools/list", Json::Null)).unwrap();
let listed = r.get("result").unwrap().get("tools").unwrap();
let Json::Array(items) = listed else {
panic!("expected an array")
};
assert_eq!(items.len(), tools().len());
for t in items {
let name = t.get("name").unwrap().as_str().unwrap();
assert!(!name.is_empty());
assert!(t.get("description").unwrap().as_str().unwrap().len() > 20);
let s = t.get("inputSchema").unwrap();
assert_eq!(s.get("type").unwrap().as_str(), Some("object"));
let Some(Json::Object(props)) = s.get("properties") else {
panic!("{name} has no properties object")
};
let Some(Json::Array(required)) = s.get("required") else {
panic!("{name} has no required list")
};
for r in required {
let r = r.as_str().expect("required entries are strings");
assert!(
props.contains_key(r),
"{name} requires {r:?}, which it never declares"
);
}
for (prop, def) in props {
let Json::Object(def) = def else {
panic!("{name}.{prop} is not a schema object")
};
assert!(
def.get("description")
.and_then(Json::as_str)
.is_some_and(|d| d.len() > 10),
"{name}.{prop} has no useful description"
);
assert!(
def.contains_key("type") || def.contains_key("enum"),
"{name}.{prop} declares neither a type nor an enum"
);
}
}
}
#[test]
fn descriptions_read_as_prose() {
let Json::Array(items) = handle(&request("tools/list", Json::Null))
.unwrap()
.get("result")
.unwrap()
.get("tools")
.unwrap()
.clone()
else {
panic!("expected an array")
};
let mut checked = 0;
for t in &items {
let name = t.get("name").unwrap().as_str().unwrap();
let schema = t.get("inputSchema").unwrap();
let Some(Json::Object(props)) = schema.get("properties") else {
panic!("{name} has no properties")
};
let mut prose = vec![(
name.to_string(),
t.get("description").unwrap().as_str().unwrap(),
)];
for (prop, def) in props {
if let Some(d) = def.get("description").and_then(Json::as_str) {
prose.push((format!("{name}.{prop}"), d));
}
}
for (what, text) in prose {
assert!(
!text.contains(" "),
"{what} contains a run of spaces, so a line continuation is \
missing: {text:?}"
);
assert!(
!text.contains('\n') && !text.contains('\t'),
"{what} contains a literal newline or tab"
);
assert!(text.trim() == text, "{what} is padded at one end");
checked += 1;
}
}
assert!(checked > 30, "only {checked} pieces of prose examined");
}
#[test]
fn the_required_list_is_the_one_the_handler_enforces() {
let Json::Array(items) = handle(&request("tools/list", Json::Null))
.unwrap()
.get("result")
.unwrap()
.get("tools")
.unwrap()
.clone()
else {
panic!("expected an array")
};
let mut with_required = 0;
let mut without = 0;
for t in &items {
let name = t.get("name").unwrap().as_str().unwrap();
let Some(Json::Array(required)) = t.get("inputSchema").unwrap().get("required") else {
panic!("{name} has no required list")
};
let response = call(name, Json::object([]));
let refused = is_error(&response);
if required.is_empty() {
assert!(
!refused,
"{name} requires nothing but refused a call with no arguments: {response}"
);
without += 1;
} else {
assert!(
refused,
"{name} lists {} required argument(s) and accepted a call with none",
required.len()
);
with_required += 1;
}
}
assert!(
with_required > 0 && without > 0,
"{with_required} tools with required arguments, {without} without"
);
assert_eq!(with_required + without, items.len());
}
#[test]
fn recommend_tool_returns_a_choice() {
let r = call(
"crypto_recommend",
Json::object([
("intent", Json::str("encrypt-message")),
("fips", Json::Bool(true)),
]),
);
assert!(!is_error(&r));
assert_eq!(
body(&r).get("recommended").unwrap().as_str(),
Some("aes-256-gcm")
);
}
#[test]
fn recommend_tool_declines_rather_than_substituting() {
let r = call(
"crypto_recommend",
Json::object([
("intent", Json::str("sign-data")),
("fips", Json::Bool(true)),
]),
);
let b = body(&r);
assert_eq!(b.get("status").unwrap().as_str(), Some("ok"));
assert_eq!(
b.get("recommended").unwrap().as_str(),
Some("ecdsa-p256-sha256")
);
let r = call(
"crypto_recommend",
Json::object([
("intent", Json::str("agree-key")),
("post_quantum", Json::Bool(true)),
]),
);
let b = body(&r);
assert_eq!(b.get("status").unwrap().as_str(), Some("ok"));
assert_eq!(b.get("recommended").unwrap().as_str(), Some("ml-kem-768"));
assert!(
b.get("mustObserve")
.and_then(|c| c.as_array())
.is_some_and(|cs| cs.iter().any(|c| c.get("id").and_then(Json::as_str)
== Some("deploy-post-quantum-in-a-hybrid"))),
"the hybrid constraint is missing from the recommendation"
);
}
#[test]
fn ontology_tools_work() {
let r = call(
"ontology_list",
Json::object([("class", Json::str("aead"))]),
);
assert!(body(&r).get("count").unwrap().as_i64().unwrap() >= 4);
let r = call(
"ontology_show",
Json::object([("algorithm", Json::str("SHA-256"))]),
);
assert_eq!(body(&r).get("id").unwrap().as_str(), Some("sha2-256"));
let r = call("crypto_rules", Json::object([]));
assert!(!is_error(&r));
assert_eq!(
body(&r).as_array().map(|a| a.len()),
Some(ic_ontology::RULES.len())
);
let r = call("ontology_errors", Json::object([]));
assert!(!is_error(&r));
}
#[test]
fn primitive_tools_produce_correct_values() {
let r = call(
"crypto_digest",
Json::object([
("algorithm", Json::str("sha2-256")),
("data", Json::str("abc")),
]),
);
assert_eq!(
body(&r).get("digest").unwrap().as_str(),
Some("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")
);
let r = call(
"crypto_hmac",
Json::object([
("algorithm", Json::str("hmac-sha2-256")),
("key", Json::str("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b")),
("data", Json::str("Hi There")),
]),
);
assert_eq!(
body(&r).get("tag").unwrap().as_str(),
Some("b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7")
);
let r = call("crypto_random", Json::object([("bytes", Json::num(16))]));
assert_eq!(body(&r).get("hex").unwrap().as_str().unwrap().len(), 32);
}
#[test]
fn seal_tool_encrypts_and_authenticates() {
let r = call(
"crypto_seal",
Json::object([
("algorithm", Json::str("aes-256-gcm")),
("key", Json::str("00".repeat(32))),
("nonce", Json::str("00".repeat(12))),
("plaintext", Json::str("data")),
]),
);
assert!(!is_error(&r));
let b = body(&r);
assert_eq!(b.get("ciphertext").unwrap().as_str().unwrap().len(), 8);
assert_eq!(b.get("tag").unwrap().as_str().unwrap().len(), 32);
let seal = |key: &str| {
call(
"crypto_seal",
Json::object([
("algorithm", Json::str("aes-256-gcm")),
("key", Json::str(key)),
("plaintext", Json::str("data")),
]),
)
};
let key = "11".repeat(32);
let first = seal(&key);
let second = seal(&key);
assert!(!is_error(&first) && !is_error(&second));
let n1 = body(&first)
.get("nonce")
.unwrap()
.as_str()
.unwrap()
.to_string();
let n2 = body(&second)
.get("nonce")
.unwrap()
.as_str()
.unwrap()
.to_string();
assert_eq!(n1.len(), 24);
assert_ne!(n1, n2);
let again = call(
"crypto_seal",
Json::object([
("algorithm", Json::str("aes-256-gcm")),
("key", Json::str(key.as_str())),
("nonce", Json::str(n1.as_str())),
("plaintext", Json::str("other")),
]),
);
assert!(is_error(&again), "a reused (key, nonce) was sealed");
let other_key = call(
"crypto_seal",
Json::object([
("algorithm", Json::str("aes-256-gcm")),
("key", Json::str("22".repeat(32))),
("nonce", Json::str(n1.as_str())),
("plaintext", Json::str("other")),
]),
);
assert!(!is_error(&other_key));
let r = call(
"crypto_seal",
Json::object([
("algorithm", Json::str("aes-256-gcm")),
("key", Json::str("00".repeat(16))),
("nonce", Json::str("00".repeat(12))),
("plaintext", Json::str("data")),
]),
);
assert!(is_error(&r));
}
#[test]
fn open_inverts_seal_and_refuses_a_bad_tag() {
let key = "33".repeat(32);
let sealed = call(
"crypto_seal",
Json::object([
("algorithm", Json::str("aes-256-gcm")),
("key", Json::str(key.as_str())),
("aad", Json::str("abcd")),
("plaintext", Json::str("round trip")),
]),
);
let b = body(&sealed);
let field = |n: &str| b.get(n).unwrap().as_str().unwrap().to_string();
let open = |tag: &str, aad: &str| {
call(
"crypto_open",
Json::object([
("algorithm", Json::str("aes-256-gcm")),
("key", Json::str(key.as_str())),
("nonce", Json::str(field("nonce"))),
("aad", Json::str(aad)),
("ciphertext", Json::str(field("ciphertext"))),
("tag", Json::str(tag)),
]),
)
};
let r = open(&field("tag"), "abcd");
assert!(!is_error(&r));
assert_eq!(
body(&r).get("plaintext").unwrap().as_str(),
Some("round trip")
);
let mut bad = field("tag");
bad.replace_range(0..2, if &bad[0..2] == "00" { "01" } else { "00" });
let r = open(&bad, "abcd");
assert!(is_error(&r), "a forged tag opened");
let r = open(&field("tag"), "abce");
assert!(is_error(&r), "the wrong associated data opened");
}
#[test]
fn verify_answers_valid_or_invalid_and_errors_only_on_malformed_input() {
use ic_core::traits::SignatureScheme;
let verify = |alg: &str, pk: &[u8], msg: &str, sig: &[u8]| {
call(
"crypto_verify",
Json::object([
("algorithm", Json::str(alg)),
("publicKey", Json::str(ic_core::codec::hex(pk))),
("message", Json::str(msg)),
("signature", Json::str(ic_core::codec::hex(sig))),
]),
)
};
let valid = |r: &Json| body(r).get("valid").and_then(|v| v.as_bool());
let sk = [7u8; 32];
let mut pk = [0u8; 65];
ic_ec::p256::EcdsaP256Sha256::public_key(&sk, &mut pk).unwrap();
let mut sig = [0u8; 64];
ic_ec::p256::EcdsaP256Sha256::sign(&sk, b"signed", &mut sig).unwrap();
assert_eq!(
valid(&verify("ecdsa-p256-sha256", &pk, "signed", &sig)),
Some(true)
);
assert_eq!(
valid(&verify("ecdsa-p256-sha256", &pk, "altered", &sig)),
Some(false)
);
assert!(is_error(&verify(
"ecdsa-p256-sha256",
&pk[..64],
"signed",
&sig
)));
let mut epk = [0u8; 32];
ic_ec::Ed25519::public_key(&sk, &mut epk).unwrap();
let mut esig = [0u8; 64];
ic_ec::Ed25519::sign(&sk, b"signed", &mut esig).unwrap();
assert_eq!(valid(&verify("ed25519", &epk, "signed", &esig)), Some(true));
esig[0] ^= 1;
assert_eq!(
valid(&verify("ed25519", &epk, "signed", &esig)),
Some(false)
);
use ironcrypto::mldsa::sign44 as m;
let mut mpk = [0u8; m::PUBLIC_KEY_LEN];
let mut msk = [0u8; m::SECRET_KEY_LEN];
assert!(m::keygen(&[9u8; 32], &mut mpk, &mut msk));
let mut msig = [0u8; m::SIGNATURE_LEN];
assert!(m::sign(&msk, b"signed", b"ctx", &[0u8; 32], &mut msig));
let with_ctx = |ctx: &str| {
call(
"crypto_verify",
Json::object([
("algorithm", Json::str("ml-dsa-44")),
("publicKey", Json::str(ic_core::codec::hex(&mpk))),
("message", Json::str("signed")),
("signature", Json::str(ic_core::codec::hex(&msig))),
("context", Json::str(ctx)),
]),
)
};
assert_eq!(valid(&with_ctx("637478")), Some(true));
assert_eq!(valid(&with_ctx("")), Some(false));
let mut rng = ic_drbg::Rng::from_entropy(&[0x45u8; 32], b"verify tool").unwrap();
let rsa = ic_rsa::generate(2048, &mut rng).unwrap();
let mut n = [0u8; 256];
rsa.public_key().modulus_bytes(&mut n).unwrap();
let e = rsa.public_key().exponent();
let mut spki = [0u8; 400];
let spki_len = ic_pkix::PublicKeyInfo::Rsa {
modulus: &n,
exponent: e,
}
.to_der(&mut spki)
.unwrap();
let mut pkcs1 = [0u8; 400];
let pkcs1_len = ic_pkix::write_rsa_public_key(&n, e, &mut pkcs1).unwrap();
let mut rsig = [0u8; 256];
ic_rsa::PssSha256::sign(&rsa, b"signed", &mut rng, &mut rsig).unwrap();
for key in [&spki[..spki_len], &pkcs1[..pkcs1_len]] {
assert_eq!(
valid(&verify("rsa-pss-sha256", key, "signed", &rsig)),
Some(true)
);
assert_eq!(
valid(&verify("rsa-pss-sha256", key, "other", &rsig)),
Some(false)
);
}
assert!(
is_error(&verify("rsa-pss-sha256", &n, "signed", &rsig)),
"a bare modulus is not a key"
);
assert!(is_error(&verify("ecdsa-p256-sha1", &pk, "signed", &sig)));
}
#[test]
fn derive_matches_rfc5869_and_bounds_its_length() {
let derive = |length: f64| {
call(
"crypto_derive",
Json::object([
("algorithm", Json::str("hkdf-sha2-256")),
("ikm", Json::str("0b".repeat(22))),
("salt", Json::str("000102030405060708090a0b0c")),
("info", Json::str("f0f1f2f3f4f5f6f7f8f9")),
("length", Json::num(length)),
]),
)
};
assert_eq!(
body(&derive(42.0)).get("okm").unwrap().as_str(),
Some("3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865")
);
assert!(is_error(&derive(0.0)));
assert!(
is_error(&derive(255.0 * 32.0 + 1.0)),
"longer than HKDF can produce"
);
}
#[test]
fn lint_tool_reports_findings_with_their_rules_and_limits() {
let r = call(
"crypto_lint",
Json::object([(
"source",
Json::str("let c = Aes256Gcm::new(&[7; 32])?;\nif tag == expected {}"),
)]),
);
assert!(!is_error(&r));
let b = body(&r);
let rules: Vec<_> = b
.get("findings")
.unwrap()
.as_array()
.unwrap()
.iter()
.map(|f| f.get("rule").unwrap().as_str().unwrap().to_string())
.collect();
assert_eq!(rules, ["no-literal-key", "no-tag-equality"]);
assert!(b.get("caveat").is_some());
}
#[test]
fn selftest_tool_reports_all_passing() {
let r = call("crypto_selftest", Json::object([]));
assert_eq!(body(&r).get("failed").unwrap().as_i64(), Some(0));
}
#[test]
fn capabilities_tool_denies_validation() {
let r = call("crypto_capabilities", Json::object([]));
assert!(body(&r)
.get("validationStatement")
.unwrap()
.as_str()
.unwrap()
.contains("NOT been submitted"));
}
#[test]
fn the_detail_view_shows_both_directions() {
let r = call(
"ontology_show",
Json::object([("algorithm", Json::str("sha2-256"))]),
);
assert!(!is_error(&r));
let b = body(&r);
let inbound = b
.get("relatedBy")
.and_then(|v| v.as_array())
.expect("the detail view must carry relatedBy");
let sources: Vec<&str> = inbound
.iter()
.filter_map(|x| x.get("source").and_then(|v| v.as_str()))
.collect();
assert!(
sources.len() >= 5,
"sha2-256 is named by several entries; got {sources:?}"
);
for expected in ["md5", "sha-1", "hmac-sha2-256"] {
assert!(
sources.contains(&expected),
"{expected} points at sha2-256 but is not listed: {sources:?}"
);
}
for item in inbound {
assert!(item.get("relation").and_then(|v| v.as_str()).is_some());
}
assert!(b.get("relations").and_then(|v| v.as_array()).is_some());
let listed = body(&call("ontology_list", Json::object([]))).to_string();
assert!(
listed.contains("sha2-256"),
"the list response should contain entries"
);
assert!(
!listed.contains("relatedBy"),
"ontology_list should not carry the inbound half for every entry"
);
}
#[test]
fn the_cli_and_this_server_agree() {
let cases = [
(
vec!["ontology", "show", "sha2-256", "--json"],
"ontology_show",
Json::object([("algorithm", Json::str("sha2-256"))]),
),
(
vec!["recommend", "encrypt-message", "--json"],
"crypto_recommend",
Json::object([("intent", Json::str("encrypt-message"))]),
),
(
vec!["ontology", "standard", "FIPS 203", "--json"],
"crypto_standard",
Json::object([("standard", Json::str("FIPS 203"))]),
),
(
vec!["ontology", "controls", "--json"],
"crypto_controls",
Json::object([]),
),
];
for (argv, tool, args) in cases {
let from_cli = crate::run(&argv).unwrap_or_else(|e| panic!("{argv:?}: {e}"));
let from_cli = ic_json::parse(&from_cli).expect("the CLI emits valid JSON");
let from_mcp = body(&call(tool, args));
assert_eq!(
from_cli,
from_mcp,
"`ic {}` and the {tool} tool returned different data",
argv.join(" ")
);
}
}
#[test]
fn response_keys_are_camel_case() {
fn walk(value: &Json, path: &str, bad: &mut Vec<String>) {
match value {
Json::Object(fields) => {
for (k, v) in fields {
if k.contains('_') {
bad.push(format!("{path}.{k}"));
}
walk(v, &format!("{path}.{k}"), bad);
}
}
Json::Array(items) => {
for (i, v) in items.iter().enumerate() {
walk(v, &format!("{path}[{i}]"), bad);
}
}
_ => {}
}
}
let calls = [
(
"crypto_recommend",
Json::object([("intent", Json::str("encrypt-message"))]),
),
("crypto_capabilities", Json::object([])),
(
"crypto_standard",
Json::object([("standard", Json::str("FIPS 203"))]),
),
("crypto_requirements", Json::object([])),
("crypto_controls", Json::object([])),
("ontology_list", Json::object([])),
(
"ontology_show",
Json::object([("algorithm", Json::str("sha2-256"))]),
),
("ontology_errors", Json::object([])),
];
let mut bad = Vec::new();
let mut checked = 0;
for (name, args) in calls {
let r = call(name, args);
assert!(!is_error(&r), "{name} returned an error");
checked += 1;
walk(&body(&r), name, &mut bad);
}
assert_eq!(checked, 8, "a tool stopped responding");
assert!(
bad.is_empty(),
"these response keys are not camelCase: {bad:?}"
);
}
#[test]
fn the_controls_tool_surfaces_what_is_not_satisfied() {
let r = call("crypto_controls", Json::object([]));
assert!(!is_error(&r));
let b = body(&r);
assert_eq!(
b.get("fipsValidated").unwrap().as_bool(),
Some(false),
"the response must state plainly that this is not validated"
);
let unmet: Vec<&str> = b
.get("unmet")
.unwrap()
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert!(
unmet.contains(&"SC.L2-3.13.11"),
"the FIPS-validation practice must be named as unmet, got {unmet:?}"
);
let r = call(
"crypto_controls",
Json::object([("control", Json::str("SC.L2-3.13.11"))]),
);
let b = body(&r);
let why = b
.get("compliance")
.unwrap()
.get("reason")
.unwrap()
.as_str()
.unwrap();
assert!(why.contains("no CMVP certificate"), "got {why}");
let r = call(
"crypto_controls",
Json::object([("framework", Json::str("cwe"))]),
);
let n = body(&r).get("count").unwrap().as_f64().unwrap();
assert!(n >= 5.0, "cwe should have several controls: {n}");
let r = call(
"crypto_controls",
Json::object([("framework", Json::str("nonsense"))]),
);
assert!(is_error(&r));
}
#[test]
fn the_standards_tool_returns_obligations_with_evidence() {
let r = call(
"crypto_standard",
Json::object([("standard", Json::str("FIPS 203"))]),
);
assert!(!is_error(&r));
let b = body(&r);
assert_eq!(b.get("id").unwrap().as_str(), Some("FIPS 203"));
assert_eq!(b.get("status").unwrap().as_str(), Some("current"));
let reqs = b.get("requirements").unwrap().as_array().unwrap();
assert!(!reqs.is_empty(), "FIPS 203 must carry requirements");
let met = reqs
.iter()
.find(|r| r.get("id").unwrap().as_str() == Some("fips-203-encaps-key-check"))
.expect("the section 7.2 check must be listed");
let c = met.get("compliance").unwrap();
assert_eq!(c.get("state").unwrap().as_str(), Some("met"));
assert!(
c.get("file").unwrap().as_str().unwrap().contains("kem.rs"),
"a met requirement must say where to look"
);
let r = call(
"crypto_standard",
Json::object([("algorithm", Json::str("ml-kem-768"))]),
);
assert!(!is_error(&r));
let docs = body(&r).get("standards").unwrap().as_array().unwrap().len();
assert!(docs >= 1, "ml-kem-768 must cite at least one document");
let r = call(
"crypto_standard",
Json::object([("standard", Json::str("FIPS 999"))]),
);
assert!(is_error(&r));
}
#[test]
fn the_requirements_tool_reports_totals_and_filters() {
let r = call("crypto_requirements", Json::object([]));
assert!(!is_error(&r));
let b = body(&r);
let totals = b.get("totals").unwrap();
let met = totals.get("met").unwrap().as_f64().unwrap();
assert!(
met >= 12.0,
"most requirements should be wired to code: {met}"
);
let all = b.get("count").unwrap().as_f64().unwrap();
let r = call(
"crypto_requirements",
Json::object([("algorithm", Json::str("ml-kem-768"))]),
);
let narrowed = body(&r).get("count").unwrap().as_f64().unwrap();
assert!(narrowed < all, "a filter must narrow: {narrowed} vs {all}");
assert!(narrowed > 0.0, "and must not narrow to nothing");
let r = call(
"crypto_requirements",
Json::object([("algorithm", Json::str("ml-kem-768"))]),
);
let scoped = body(&r);
let ids: Vec<&str> = scoped
.get("requirements")
.unwrap()
.as_array()
.unwrap()
.iter()
.filter_map(|x| x.get("id").and_then(|v| v.as_str()))
.collect();
assert!(
ids.iter().any(|i| i.starts_with("fips-140-3")),
"module-wide obligations must match too, got {ids:?}"
);
let r = call(
"crypto_requirements",
Json::object([("state", Json::str("nonsense"))]),
);
assert!(is_error(&r));
}
#[test]
fn tool_failures_are_reported_in_band() {
let r = call(
"crypto_digest",
Json::object([("algorithm", Json::str("sha2-256"))]),
);
assert!(is_error(&r));
assert!(body(&r)
.get("error")
.unwrap()
.as_str()
.unwrap()
.contains("data"));
let r = call(
"crypto_hmac",
Json::object([
("algorithm", Json::str("hmac-sha2-256")),
("key", Json::str("not-hex")),
("data", Json::str("x")),
]),
);
assert!(is_error(&r));
}
#[test]
fn unknown_methods_and_tools_produce_protocol_errors() {
let r = handle(&request("does/not/exist", Json::Null)).unwrap();
assert_eq!(
r.get("error").unwrap().get("code").unwrap().as_i64(),
Some(-32601)
);
let r = call("no_such_tool", Json::object([]));
assert_eq!(
r.get("error").unwrap().get("code").unwrap().as_i64(),
Some(-32601)
);
}
#[test]
fn notifications_are_not_answered() {
let notification = Json::object([
("jsonrpc", Json::str("2.0")),
("method", Json::str("notifications/initialized")),
]);
assert!(handle(¬ification).is_none());
}
#[test]
fn every_response_is_valid_json() {
for r in [
handle(&request("initialize", Json::Null)).unwrap(),
handle(&request("tools/list", Json::Null)).unwrap(),
call("crypto_capabilities", Json::object([])),
] {
let text = r.to_string();
parse(&text).unwrap_or_else(|e| panic!("invalid response JSON: {e}\n{text}"));
}
}
}