First Version
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
//! Reference generator for the crypto interop test vectors
|
||||
//! (data/ios-app/test-vectors.md §3). This is the *source of truth*: run it once
|
||||
//! and paste the output into test-vectors.md §4, then commit.
|
||||
//!
|
||||
//! cargo run -p skald-relay-common --bin gen-vectors
|
||||
//!
|
||||
//! The relay itself only verifies Ed25519 and derives namespace_id; the full
|
||||
//! E2E suite (X25519/HKDF/AES-GCM) lives in `skald-relay-common::crypto` so
|
||||
//! independent implementations (Swift app, Kotlin app) can assert byte-for-byte
|
||||
//! equality. This binary is a thin driver over those library functions, so the
|
||||
//! plugin, relay tests and the generator all share the exact same code path.
|
||||
//!
|
||||
//! ## v2 framing (data/iOS-app/v2/framing.md §1)
|
||||
//!
|
||||
//! In v2 the bytes that get fed to AES-GCM are not the raw JSON plaintext: the
|
||||
//! sender wraps them in a tiny envelope
|
||||
//!
|
||||
//! ```text
|
||||
//! plaintext = version (0x01) ‖ comp (1B) ‖ payload(JSON)
|
||||
//! ```
|
||||
//!
|
||||
//! where `comp = 0x00` (no compression) when `len(payload) <= 1024`, else
|
||||
//! `comp = 0x01` (zlib). See [`crypto::compress_payload`] for the implementation.
|
||||
//! The reference vectors below seal that framed byte stream; interop consumers
|
||||
//! must do the same.
|
||||
|
||||
use base64::{Engine, engine::general_purpose::STANDARD as B64};
|
||||
use skald_relay_common::crypto::{
|
||||
DIR_AGENT_TO_CLIENT, DIR_CLIENT_TO_AGENT, build_aad, build_nonce, compress_payload,
|
||||
decompress_payload, derive_aes_key, derive_keys, ecdh, namespace_id, seal, sign_challenge,
|
||||
};
|
||||
|
||||
// Exact UTF-8 plaintexts from test-vectors.md §1 (no spaces, no field reorder).
|
||||
const PLAINTEXT_A2C: &[u8] = br#"{"v":1,"kind":"inbox_update","id":"00000000-0000-4000-8000-000000000001","ts":1750000000000,"badge":1,"approvals":[{"request_id":"appr_test_1","tool_name":"send_email","agent_label":"Skald","summary":"Test","created_at":1750000000000}],"clarifications":[]}"#;
|
||||
const PLAINTEXT_C2A: &[u8] = br#"{"v":1,"kind":"approval_response","id":"00000000-0000-4000-8000-000000000002","ts":1750000000000,"request_id":"appr_test_1","decision":"approved"}"#;
|
||||
|
||||
fn main() {
|
||||
let seed_a: [u8; 32] = (0u8..32).collect::<Vec<_>>().try_into().unwrap();
|
||||
let seed_c: [u8; 32] = (32u8..64).collect::<Vec<_>>().try_into().unwrap();
|
||||
|
||||
// --- key derivation (crypto.md §3) ---
|
||||
let a = derive_keys(&seed_a);
|
||||
let c = derive_keys(&seed_c);
|
||||
|
||||
// --- namespace_id (crypto.md §7) ---
|
||||
let (ns_raw, ns_hex) = namespace_id(&a.ed25519_pub);
|
||||
|
||||
// --- ECDH + AEAD key (crypto.md §4-5), with the mandatory self-check ---
|
||||
let s1 = ecdh(&a.x25519_priv, &c.x25519_pub);
|
||||
let s2 = ecdh(&c.x25519_priv, &a.x25519_pub);
|
||||
assert_eq!(s1, s2, "ECDH mismatch");
|
||||
let shared = s1;
|
||||
let aes_key = derive_aes_key(&shared);
|
||||
|
||||
// --- A2C (agent → client) ---
|
||||
let n_a2c = build_nonce(DIR_AGENT_TO_CLIENT, 1);
|
||||
let aad_a2c = build_aad(&ns_raw, &a.ed25519_pub, &c.ed25519_pub);
|
||||
|
||||
// v2 framing: wrap the JSON in version+comp+payload before sealing.
|
||||
let framed_a2c = compress_payload(PLAINTEXT_A2C);
|
||||
// Self-check: decompress_payload is the exact inverse of compress_payload.
|
||||
assert_eq!(
|
||||
decompress_payload(&framed_a2c).unwrap(),
|
||||
PLAINTEXT_A2C,
|
||||
"A2C framing round-trip mismatch"
|
||||
);
|
||||
// In v2 the AES-GCM input is the *framed* plaintext (framing.md §1).
|
||||
let sealed_a2c = seal(&aes_key, &n_a2c, &aad_a2c, &framed_a2c).unwrap();
|
||||
|
||||
// --- C2A (client → agent) ---
|
||||
let n_c2a = build_nonce(DIR_CLIENT_TO_AGENT, 1);
|
||||
let aad_c2a = build_aad(&ns_raw, &c.ed25519_pub, &a.ed25519_pub);
|
||||
|
||||
let framed_c2a = compress_payload(PLAINTEXT_C2A);
|
||||
assert_eq!(
|
||||
decompress_payload(&framed_c2a).unwrap(),
|
||||
PLAINTEXT_C2A,
|
||||
"C2A framing round-trip mismatch"
|
||||
);
|
||||
let sealed_c2a = seal(&aes_key, &n_c2a, &aad_c2a, &framed_c2a).unwrap();
|
||||
|
||||
// --- auth signature (client), crypto.md §8 ---
|
||||
let challenge: [u8; 32] =
|
||||
hex::decode("aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899")
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
let sig = sign_challenge(&c.signing_key(), &challenge);
|
||||
|
||||
// Round-trip self-check: decrypt what we sealed. In v2 this recovers the
|
||||
// *framed* plaintext (not the raw JSON); a real receiver would then call
|
||||
// decompress_payload to peel off the version+comp header.
|
||||
let dec =
|
||||
skald_relay_common::crypto::open(&aes_key, &n_a2c, &aad_a2c, &sealed_a2c).unwrap();
|
||||
assert_eq!(dec, framed_a2c, "A2C round-trip mismatch");
|
||||
assert_eq!(
|
||||
decompress_payload(&dec).unwrap(),
|
||||
PLAINTEXT_A2C,
|
||||
"A2C decompress after open mismatch"
|
||||
);
|
||||
|
||||
println!("# Generated by `cargo run -p skald-relay-common --bin gen-vectors`");
|
||||
println!("# v2 framing (data/ios-app/v2/framing.md §1): the bytes fed to AES-GCM");
|
||||
println!("# are plaintext = version(0x01) ‖ comp(1B) ‖ payload(JSON). Below the");
|
||||
println!("# threshold (1024B), comp = 0x00 and payload is the raw JSON. V14/V17");
|
||||
println!("# below are computed by sealing the FRAMED plaintext, not the raw JSON.");
|
||||
println!("V1 agent_x25519_priv = {}", hex::encode(a.x25519_priv));
|
||||
println!("V2 agent_x25519_pub = {}", hex::encode(a.x25519_pub));
|
||||
println!("V3 agent_ed25519_priv = {}", hex::encode(a.ed25519_priv));
|
||||
println!("V4 agent_ed25519_pub = {}", hex::encode(a.ed25519_pub));
|
||||
println!("V5 client_x25519_priv = {}", hex::encode(c.x25519_priv));
|
||||
println!("V6 client_x25519_pub = {}", hex::encode(c.x25519_pub));
|
||||
println!("V7 client_ed25519_priv= {}", hex::encode(c.ed25519_priv));
|
||||
println!("V8 client_ed25519_pub = {}", hex::encode(c.ed25519_pub));
|
||||
println!("V9 namespace_id = {}", ns_hex);
|
||||
println!("V10 shared_secret = {}", hex::encode(shared));
|
||||
println!("V11 aes_key = {}", hex::encode(aes_key));
|
||||
println!("V12 nonce_a2c = {}", hex::encode(n_a2c));
|
||||
println!("V13 aad_a2c = {}", hex::encode(&aad_a2c));
|
||||
println!("V14 sealed_a2c (b64) = {}", B64.encode(&sealed_a2c));
|
||||
println!("V15 nonce_c2a = {}", hex::encode(n_c2a));
|
||||
println!("V16 aad_c2a = {}", hex::encode(&aad_c2a));
|
||||
println!("V17 sealed_c2a (b64) = {}", B64.encode(&sealed_c2a));
|
||||
println!("V18 auth_sig_client = {}", hex::encode(sig));
|
||||
println!();
|
||||
println!("# v2 framed plaintexts (input to AES-GCM, framing.md §1):");
|
||||
println!(
|
||||
"PT_FRAMED_A2C = {}",
|
||||
hex::encode(&framed_a2c)
|
||||
);
|
||||
println!(
|
||||
"PT_FRAMED_C2A = {}",
|
||||
hex::encode(&framed_c2a)
|
||||
);
|
||||
println!(
|
||||
"# framed_a2c[:2] = {:02x}{:02x} (version=01, comp=00 = none for <1024 B)",
|
||||
framed_a2c[0], framed_a2c[1]
|
||||
);
|
||||
println!(
|
||||
"# framed_c2a[:2] = {:02x}{:02x} (version=01, comp=00 = none for <1024 B)",
|
||||
framed_c2a[0], framed_c2a[1]
|
||||
);
|
||||
println!(
|
||||
"# PT_FRAMED_A2C.len = {} (PLAINTEXT_A2C.len + 2 framing header)",
|
||||
framed_a2c.len()
|
||||
);
|
||||
println!(
|
||||
"# PT_FRAMED_C2A.len = {} (PLAINTEXT_C2A.len + 2 framing header)",
|
||||
framed_c2a.len()
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user