- 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)
260 lines
No EOL
9.6 KiB
Rust
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"
|
|
);
|
|
}
|
|
} |