use ed25519_dalek::{Signature, SigningKey, Verifier, VerifyingKey, SECRET_KEY_LENGTH};
use serde_json::{Map, Value};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ProtocolError {
#[error("invalid hex: {0}")]
Hex(#[from] hex::FromHexError),
#[error("invalid public key length: expected 32 bytes, got {0}")]
PublicKeyLength(usize),
#[error("invalid private key length: expected 32 bytes, got {0}")]
PrivateKeyLength(usize),
#[error("invalid signature length: expected 64 bytes, got {0}")]
SignatureLength(usize),
#[error("invalid public key bytes")]
PublicKey,
#[error("canonicalization failed: {0}")]
Canonicalization(#[from] serde_json::Error),
}
pub fn canonicalize(value: &Value) -> Result<Vec<u8>, ProtocolError> {
let mut out = Vec::with_capacity(64);
write_canonical(value, &mut out)?;
Ok(out)
}
fn write_canonical(value: &Value, out: &mut Vec<u8>) -> Result<(), ProtocolError> {
match value {
Value::Object(map) => {
let sorted = sort_object(map);
out.push(b'{');
for (i, (k, v)) in sorted.iter().enumerate() {
if i > 0 {
out.push(b',');
}
write_ascii_string(k, out);
out.push(b':');
write_canonical(v, out)?;
}
out.push(b'}');
}
Value::Array(arr) => {
out.push(b'[');
for (i, v) in arr.iter().enumerate() {
if i > 0 {
out.push(b',');
}
write_canonical(v, out)?;
}
out.push(b']');
}
Value::String(s) => {
write_ascii_string(s, out);
}
other => {
serde_json::to_writer(&mut *out, other)?;
}
}
Ok(())
}
fn write_ascii_string(s: &str, out: &mut Vec<u8>) {
out.push(b'"');
for ch in s.chars() {
match ch {
'"' => out.extend_from_slice(b"\\\""),
'\\' => out.extend_from_slice(b"\\\\"),
'\u{0008}' => out.extend_from_slice(b"\\b"),
'\u{0009}' => out.extend_from_slice(b"\\t"),
'\u{000a}' => out.extend_from_slice(b"\\n"),
'\u{000c}' => out.extend_from_slice(b"\\f"),
'\u{000d}' => out.extend_from_slice(b"\\r"),
c if (c as u32) < 0x20 => {
write_unicode_escape(c as u32, out);
}
c if (c as u32) < 0x7f => {
out.push(c as u8);
}
c => {
let cp = c as u32;
if cp <= 0xffff {
write_unicode_escape(cp, out);
} else {
let v = cp - 0x10000;
let high = 0xd800 + (v >> 10);
let low = 0xdc00 + (v & 0x3ff);
write_unicode_escape(high, out);
write_unicode_escape(low, out);
}
}
}
}
out.push(b'"');
}
fn write_unicode_escape(cp: u32, out: &mut Vec<u8>) {
out.extend_from_slice(b"\\u");
let nibbles = [(cp >> 12) & 0xf, (cp >> 8) & 0xf, (cp >> 4) & 0xf, cp & 0xf];
for n in nibbles {
#[allow(clippy::cast_possible_truncation)]
let n = n as u8;
let byte = if n < 10 { b'0' + n } else { b'a' + (n - 10) };
out.push(byte);
}
}
fn sort_object(map: &Map<String, Value>) -> Vec<(&String, &Value)> {
let mut entries: Vec<(&String, &Value)> = map.iter().collect();
entries.sort_by(|a, b| a.0.cmp(b.0));
entries
}
fn decode_hex_fixed<const N: usize>(s: &str) -> Result<[u8; N], ProtocolError> {
let raw = hex::decode(s)?;
if raw.len() != N {
return match N {
32 if s.len() == 64 => Err(ProtocolError::PublicKeyLength(raw.len())),
64 => Err(ProtocolError::SignatureLength(raw.len())),
_ => Err(ProtocolError::PublicKeyLength(raw.len())),
};
}
let mut out = [0u8; N];
out.copy_from_slice(&raw);
Ok(out)
}
pub fn verify_signature_hex(
public_key_hex: &str,
payload: &[u8],
signature_hex: &str,
) -> Result<bool, ProtocolError> {
let pk_bytes = decode_hex_fixed::<32>(public_key_hex)
.map_err(|_| ProtocolError::PublicKeyLength(public_key_hex.len() / 2))?;
let sig_bytes_raw = hex::decode(signature_hex)?;
if sig_bytes_raw.len() != 64 {
return Err(ProtocolError::SignatureLength(sig_bytes_raw.len()));
}
let mut sig_bytes = [0u8; 64];
sig_bytes.copy_from_slice(&sig_bytes_raw);
let pk = VerifyingKey::from_bytes(&pk_bytes).map_err(|_| ProtocolError::PublicKey)?;
let sig = Signature::from_bytes(&sig_bytes);
Ok(pk.verify(payload, &sig).is_ok())
}
pub fn sign_payload_hex(secret_key_hex: &str, payload: &[u8]) -> Result<String, ProtocolError> {
let sk_raw = hex::decode(secret_key_hex)?;
if sk_raw.len() != SECRET_KEY_LENGTH {
return Err(ProtocolError::PrivateKeyLength(sk_raw.len()));
}
let mut sk_bytes = [0u8; SECRET_KEY_LENGTH];
sk_bytes.copy_from_slice(&sk_raw);
let signing = SigningKey::from_bytes(&sk_bytes);
let sig: Signature = ed25519_dalek::Signer::sign(&signing, payload);
Ok(hex::encode(sig.to_bytes()))
}
pub fn verify_envelope_hex(
public_key_hex: &str,
envelope: &Value,
signature_hex: &str,
) -> Result<bool, ProtocolError> {
let canonical = canonicalize(envelope)?;
verify_signature_hex(public_key_hex, &canonical, signature_hex)
}
pub fn sign_envelope_hex(secret_key_hex: &str, envelope: &Value) -> Result<String, ProtocolError> {
let canonical = canonicalize(envelope)?;
sign_payload_hex(secret_key_hex, &canonical)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn canonicalize_sorts_keys() {
let v = json!({"b": 1, "a": 2, "c": [3, 4]});
let s = canonicalize(&v).unwrap();
assert_eq!(
std::str::from_utf8(&s).unwrap(),
r#"{"a":2,"b":1,"c":[3,4]}"#
);
}
#[test]
fn canonicalize_nested_objects() {
let v = json!({"outer": {"z": 1, "a": 2}, "x": 3});
let s = canonicalize(&v).unwrap();
assert_eq!(
std::str::from_utf8(&s).unwrap(),
r#"{"outer":{"a":2,"z":1},"x":3}"#
);
}
#[test]
fn canonicalize_ensure_ascii_escapes_non_bmp() {
let v = json!({"unicode": "héllo", "emoji": "🚀"});
let s = canonicalize(&v).unwrap();
assert_eq!(
std::str::from_utf8(&s).unwrap(),
r#"{"emoji":"\ud83d\ude80","unicode":"h\u00e9llo"}"#
);
}
#[test]
fn canonicalize_escapes_control_chars() {
let v = json!({"k": "a\nb\tc\u{0001}d\""});
let s = canonicalize(&v).unwrap();
assert_eq!(
std::str::from_utf8(&s).unwrap(),
r#"{"k":"a\nb\tc\u0001d\""}"#
);
}
#[test]
fn sign_then_verify_roundtrip() {
let sk_hex = "1".repeat(64);
let signing =
SigningKey::from_bytes(&<[u8; 32]>::try_from(hex::decode(&sk_hex).unwrap()).unwrap());
let pk_hex = hex::encode(signing.verifying_key().to_bytes());
let envelope = json!({"op": "register", "runner_id": "abc", "ts": 1234});
let sig = sign_envelope_hex(&sk_hex, &envelope).unwrap();
assert!(verify_envelope_hex(&pk_hex, &envelope, &sig).unwrap());
let tampered = json!({"op": "register", "runner_id": "abc", "ts": 9999});
assert!(!verify_envelope_hex(&pk_hex, &tampered, &sig).unwrap());
}
#[test]
fn bad_signature_length_is_err() {
let pk_hex = "0".repeat(64);
let payload = b"hello";
let result = verify_signature_hex(&pk_hex, payload, "ab");
assert!(matches!(result, Err(ProtocolError::SignatureLength(_))));
}
const CANONICAL_FIXTURE: &str =
include_str!("../../../tests/fixtures/phase_2_9/signed_command_canonical.json");
#[test]
fn canonical_matches_signed_command_fixture() {
let doc: serde_json::Value =
serde_json::from_str(CANONICAL_FIXTURE).expect("parse signed_command_canonical.json");
let cases = doc["cases"].as_array().expect("cases array");
assert!(!cases.is_empty(), "fixture must have at least one case");
for case in cases {
let name = case["name"].as_str().unwrap_or("?");
let envelope = &case["envelope"];
let expected = case["expected_canonical"]
.as_str()
.unwrap_or_else(|| panic!("case {name}: expected_canonical must be a string"));
let got = canonicalize(envelope)
.unwrap_or_else(|e| panic!("case {name}: canonicalize error: {e}"));
assert_eq!(
std::str::from_utf8(&got).unwrap(),
expected,
"case {name}: canonical mismatch"
);
}
}
}