niko_trust_gui/tests/tce_vectors.rs
Niko Marmeladkov 6278321873
niko_trust_gui: TCE protocol client with iced GUI
- Byte-exact TCE codec (encoder/decoder, canonical numbers, vectors-tested)
- Ed25519 identities with bech32m trust1… addresses
- Relay client: challenge/auth handshake, request feed, responses,
  transparent re-auth on 401
- iced GUI (Material-dark, Solana-style palette): tab shell, approval
  prompts with native toasts on Linux, address book, history, settings,
  password change
- Portable single-file vault (identity + book + history + settings)
- Live 2FA roundtrip examples (send_2fa, await_2fa)
2026-08-22 23:20:41 +03:00

260 lines
No EOL
9.6 KiB
Rust

//! Byte-exact conformance against the frozen TCE vectors produced by the
//! reference implementation. The vectors file lives in the relay repo at
//! ~/niko_trust/testdata/vectors/tce_vectors.json and is the normative ground
//! truth. If the file is absent the tests are skipped (keeps this crate
//! self-contained).
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use niko_trust_gui::address::Address;
use niko_trust_gui::protocol::{self, Decision, MAX_CLOCK_SKEW};
use niko_trust_gui::signer::{self, Signer};
use niko_trust_gui::tce::number::canonical_number;
use niko_trust_gui::tce::value::Value;
use serde_json::Value as Json;
fn vectors_path() -> PathBuf {
if let Ok(p) = std::env::var("NIKO_VECTORS") {
return PathBuf::from(p);
}
let d = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".into());
Path::new(&d).join("../niko_trust/testdata/vectors/tce_vectors.json")
}
fn load_vectors() -> Option<Json> {
let p = vectors_path();
if !p.exists() {
eprintln!("skipping: vectors file not found at {}", p.display());
return None;
}
let raw = std::fs::read_to_string(&p).expect("read vectors");
serde_json::from_str(&raw).map(Some).expect("parse vectors json")
}
fn pubkey_of(addr: &str) -> [u8; 32] {
let a = Address::parse(addr).expect("valid address");
*a.pubkey()
}
fn seed_for_pubkey(parties: &Json, pubkey_hex: &str) -> [u8; 32] {
for (_name, p) in parties.as_object().unwrap() {
if p["pubkey_hex"].as_str() == Some(pubkey_hex) {
let s = hex::decode(p["seed_hex"].as_str().unwrap()).unwrap();
return s.try_into().unwrap();
}
}
panic!("party not found for pubkey {pubkey_hex}")
}
fn signer_for(parties: &Json, pubkey_hex: &str) -> Signer {
Signer::from_seed(seed_for_pubkey(parties, pubkey_hex)).expect("signer")
}
fn hex_bytes(s: &str) -> Vec<u8> {
hex::decode(s).expect("hex")
}
fn value_from_json(v: &Json) -> Value {
match v {
Json::Null => Value::Null,
Json::Bool(b) => Value::Bool(*b),
Json::String(s) => Value::Str(s.clone()),
Json::Number(n) => Value::Number(n.to_string()),
other => panic!("unsupported value in vector json: {other}"),
}
}
fn map_from_json(v: &Json) -> BTreeMap<String, Value> {
let mut m = BTreeMap::new();
for (k, val) in v.as_object().unwrap() {
m.insert(k.clone(), value_from_json(val));
}
m
}
fn nonce_from_json(v: &Json) -> [u8; 16] {
hex_bytes(v.as_str().unwrap()).try_into().unwrap()
}
fn hash_from_json(v: &Json) -> [u8; 32] {
hex_bytes(v.as_str().unwrap()).try_into().unwrap()
}
fn check_vector(parties: &Json, v: &Json) {
let tce_expected = hex_bytes(v["tce_hex"].as_str().unwrap());
let object_id = v["object_id_hex"].as_str().unwrap();
let signer_key = v["signer_pubkey_hex"].as_str().unwrap();
let _ = v["signature_hex"];
let j = &v["json"];
let typ = j["type"].as_str().unwrap();
let signer = signer_for(parties, signer_key);
// Build the typed object from the json view.
let encoded: Vec<u8> = match typ {
"identity" => protocol::encode_identity(&protocol::Identity {
pubkey: signer.pubkey(),
alias: j["alias"].as_str().unwrap().to_string(),
created_at: j["created_at"].as_u64().unwrap(),
})
.unwrap(),
"claim" => protocol::encode_claim(&protocol::Claim {
issuer: signer.pubkey(),
subject: pubkey_of(j["subject"].as_str().unwrap()),
claims: map_from_json(&j["claims"]),
created_at: j["created_at"].as_u64().unwrap(),
expires_at: j["expires_at"].as_u64().unwrap(),
serial: j["serial"].as_u64().unwrap(),
nonce: nonce_from_json(&j["nonce"]),
})
.unwrap(),
"revocation" => protocol::encode_revocation(&protocol::Revocation {
issuer: signer.pubkey(),
claim_id: hash_from_json(&j["claim_id"]),
reason: j["reason"].as_str().unwrap().to_string(),
created_at: j["created_at"].as_u64().unwrap(),
nonce: nonce_from_json(&j["nonce"]),
})
.unwrap(),
"approval_request" => protocol::encode_approval_request(&protocol::ApprovalRequest {
sender: signer.pubkey(),
recipient: pubkey_of(j["recipient"].as_str().unwrap()),
action: j["action"].as_str().unwrap().to_string(),
payload: map_from_json(&j["payload"]),
message: j["message"].as_str().unwrap().to_string(),
created_at: j["created_at"].as_u64().unwrap(),
expires_at: j["expires_at"].as_u64().unwrap(),
nonce: nonce_from_json(&j["nonce"]),
})
.unwrap(),
"approval_response" => {
let decision = match j["decision"].as_str().unwrap() {
"allow" => Decision::Allow,
"deny" => Decision::Deny,
other => panic!("bad decision {other}"),
};
protocol::encode_approval_response(&protocol::ApprovalResponse {
request_hash: hash_from_json(&j["request_hash"]),
responder: signer.pubkey(),
decision,
created_at: j["created_at"].as_u64().unwrap(),
nonce: nonce_from_json(&j["nonce"]),
})
.unwrap()
}
"auth_assertion" => protocol::encode_auth_assertion(&protocol::AuthAssertion {
pubkey: signer.pubkey(),
challenge: hash_from_json(&j["challenge"]),
scope: j["scope"].as_str().unwrap().to_string(),
audience: j["audience"].as_str().unwrap().to_string(),
created_at: j["created_at"].as_u64().unwrap(),
})
.unwrap(),
other => panic!("unknown vector type {other}"),
};
assert_eq!(encoded, tce_expected, "vector {}: encoded bytes differ", v["name"]);
assert_eq!(
hex::encode(niko_trust_gui::crypto::sha256(&encoded)),
object_id,
"vector {}: object id differs",
v["name"]
);
// Deterministic Ed25519 must reproduce the reference signature.
let sig = signer.sign(&encoded);
assert_eq!(hex::encode(sig), v["signature_hex"], "vector {}: signature differs", v["name"]);
// Rule checks on the decoded form.
let _ = MAX_CLOCK_SKEW;
// decode -> re-encode round trip must be byte-identical.
let reencoded: Vec<u8> = match typ {
"identity" => {
let d = protocol::decode_identity(&tce_expected).unwrap();
protocol::encode_identity(&d).unwrap()
}
"claim" => {
let d = protocol::decode_claim(&tce_expected).unwrap();
protocol::encode_claim(&d).unwrap()
}
"revocation" => {
let d = protocol::decode_revocation(&tce_expected).unwrap();
protocol::encode_revocation(&d).unwrap()
}
"approval_request" => {
let d = protocol::decode_approval_request(&tce_expected).unwrap();
protocol::encode_approval_request(&d).unwrap()
}
"approval_response" => {
let d = protocol::decode_approval_response(&tce_expected).unwrap();
protocol::encode_approval_response(&d).unwrap()
}
"auth_assertion" => {
let d = protocol::decode_auth_assertion(&tce_expected).unwrap();
protocol::encode_auth_assertion(&d).unwrap()
}
_ => unreachable!(),
};
assert_eq!(reencoded, tce_expected, "vector {}: decode/re-encode differs", v["name"]);
// Signature verification over the exact bytes with the embedded key.
let verify_pub = match typ {
"claim" => pubkey_of(j["issuer"].as_str().unwrap()),
"approval_response" => signer.pubkey(),
"revocation" => signer.pubkey(),
"approval_request" => signer.pubkey(),
"auth_assertion" => signer.pubkey(),
"identity" => signer.pubkey(),
_ => unreachable!(),
};
let sig_vec = hex_bytes(v["signature_hex"].as_str().unwrap());
let sig_arr: [u8; 64] = sig_vec.try_into().unwrap();
signer::verify(&verify_pub, &tce_expected, &sig_arr).expect("signature must verify");
}
#[test]
fn frozen_tce_vectors_byte_exact() {
let Some(doc) = load_vectors() else { return };
let parties = &doc["parties"];
for v in doc["vectors"].as_array().unwrap() {
check_vector(parties, v);
}
assert!(doc["vectors"].as_array().unwrap().len() >= 9);
}
#[test]
fn number_canonicalization_vectors() {
let Some(doc) = load_vectors() else { return };
let nc = &doc["number_canonicalization"];
for (input, want) in nc["accept"].as_object().unwrap() {
assert_eq!(
canonical_number(input).ok(),
Some(want.as_str().unwrap().to_string()),
"accept {input}"
);
}
for (input, _reason) in nc["reject"].as_object().unwrap() {
assert!(canonical_number(input).is_err(), "reject {input}");
}
}
#[test]
fn strict_decoder_rejects() {
let Some(doc) = load_vectors() else { return };
// Every reject vector is hex bytes that a strict decoder must refuse as a
// Claim (the reference uses a claim context for these). Failing to decode
// is the assertion.
for r in doc["rejects"].as_array().unwrap() {
let name = r["name"].as_str().unwrap();
// Some reject rows are prose descriptions without bytes; only rows
// carrying tce_hex are byte-checkable here.
let Some(b) = r["tce_hex"].as_str().map(hex_bytes) else {
continue;
};
assert!(
niko_trust_gui::protocol::decode_claim(&b).is_err(),
"reject vector {name} must fail to decode"
);
}
}