diff --git a/licensing-service/tests/crosscheck.rs b/licensing-service/tests/crosscheck.rs new file mode 100644 index 0000000..ad9b54b --- /dev/null +++ b/licensing-service/tests/crosscheck.rs @@ -0,0 +1,159 @@ +//! Daemon-side wire-format crosscheck against the shared vector at +//! `tests/crosscheck/vector.json` (at the repo root, two levels up +//! from this crate). +//! +//! Each fixture in vector.json was generated by `reference_signer.py` +//! using independent Python crypto, then is consumed by: +//! - the TypeScript SDK (via tests/crosscheck/run_ts.mjs) +//! - the Python SDK (via licensing-client-python/tests/test_crosscheck.py) +//! - the Rust SDK (via its own unit tests against the same byte layouts) +//! +//! What was missing: the **daemon itself** never ran against the same +//! vectors. Drift in the daemon's parser (which uses the same byte +//! layouts but shares no code with the SDKs) could ship undetected +//! until an SDK on the wire couldn't validate a daemon-issued key. +//! +//! This file closes the loop. For every fixture we call +//! `crypto::parse_key` and assert that every parsed field matches +//! the `expected` block byte-for-byte. If a future change to the +//! daemon's parser drifts away from the wire format the SDKs +//! enforce, this fails before the build even lands in a .s9pk. +//! +//! Signature verification is intentionally NOT included here — the +//! crypto unit tests in `src/crypto/mod.rs` already prove the +//! daemon's sign+verify roundtrip works, and the SDKs prove the +//! same key verifies in three independent crypto implementations. +//! What hadn't been pinned was the parser-to-fields contract, which +//! is what this file enforces. + +use keysat::crypto; +use serde_json::Value; +use uuid::Uuid; + +const VECTOR_PATH: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../tests/crosscheck/vector.json" +); + +fn load_vector() -> Value { + let raw = std::fs::read_to_string(VECTOR_PATH).unwrap_or_else(|e| { + panic!("crosscheck vector not at {VECTOR_PATH}: {e}\n\ + If you cloned without the parent repo, make sure the \ + tests/crosscheck/ directory is present.") + }); + serde_json::from_str(&raw).expect("vector.json should be valid JSON") +} + +/// Run one fixture. Asserts every `expected` field matches the +/// daemon's `parse_key` output. The vector's field names mirror +/// what the SDKs use, which is camelCase; the daemon uses snake_case +/// for the same things internally. +fn check_fixture(name: &str, fixture: &Value) { + let key = fixture["licenseKey"] + .as_str() + .unwrap_or_else(|| panic!("{name}: missing licenseKey")); + let expected = &fixture["expected"]; + + let (payload, _signature, _signed_bytes) = crypto::parse_key(key) + .unwrap_or_else(|e| panic!("{name}: parse_key failed: {e}")); + + // Version + let want_version = expected["version"].as_u64().unwrap(); + assert_eq!( + payload.version as u64, want_version, + "{name}: version mismatch" + ); + + // UUIDs + let want_product = Uuid::parse_str(expected["productUuid"].as_str().unwrap()).unwrap(); + assert_eq!(payload.product_id, want_product, "{name}: product_id"); + + let want_license = Uuid::parse_str(expected["licenseUuid"].as_str().unwrap()).unwrap(); + assert_eq!(payload.license_id, want_license, "{name}: license_id"); + + // Times (Unix seconds) + let want_issued = expected["issuedAt"].as_i64().unwrap(); + assert_eq!(payload.issued_at, want_issued, "{name}: issued_at"); + + let want_expires = expected["expiresAt"].as_i64().unwrap(); + assert_eq!(payload.expires_at, want_expires, "{name}: expires_at"); + + // Flags + derived bits + let want_flags = expected["flags"].as_u64().unwrap(); + assert_eq!(payload.flags as u64, want_flags, "{name}: flags"); + + let want_fp_bound = expected["isFingerprintBound"].as_bool().unwrap(); + assert_eq!( + payload.is_fingerprint_bound(), + want_fp_bound, + "{name}: is_fingerprint_bound" + ); + + let want_trial = expected["isTrial"].as_bool().unwrap(); + assert_eq!(payload.is_trial(), want_trial, "{name}: is_trial"); + + // Entitlements (order-sensitive — wire format preserves order) + let want_ents: Vec = expected["entitlements"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect(); + assert_eq!(payload.entitlements, want_ents, "{name}: entitlements"); + + // Fingerprint hash. The `expected.fingerprintHashHex` is what the + // wire format must contain; we compare against payload's bytes. + let want_fp_hex = expected["fingerprintHashHex"].as_str().unwrap(); + let got_fp_hex = hex::encode(payload.fingerprint_hash); + assert_eq!(got_fp_hex, want_fp_hex, "{name}: fingerprint_hash"); + + // Cross-check: if `fingerprintRaw` is provided and binding is + // active, hashing the raw fingerprint with the daemon's helper + // should produce the same hex. This locks in the + // `hash_fingerprint` SHA-256 contract that SDKs rely on. + if want_fp_bound { + if let Some(raw) = expected["fingerprintRaw"].as_str() { + let computed = crypto::hash_fingerprint(raw); + let computed_hex = hex::encode(computed); + assert_eq!( + computed_hex, want_fp_hex, + "{name}: hash_fingerprint(raw) does not match wire bytes" + ); + } + } +} + +#[test] +fn vector_v1_legacy_roundtrip() { + let vector = load_vector(); + check_fixture("v1", &vector["v1"]); +} + +#[test] +fn vector_v2_trial_with_entitlements() { + let vector = load_vector(); + check_fixture("v2", &vector["v2"]); +} + +#[test] +fn vector_v2_perpetual_unbound() { + let vector = load_vector(); + check_fixture("v2_perpetual_unbound", &vector["v2_perpetual_unbound"]); +} + +/// The vector file's publicKeyPem is the master crosscheck key — +/// every fixture's signature was made against that PEM. This test +/// just locks in that the file has it (so a future regeneration of +/// the vectors keeps the public-key surface accessible to all +/// tooling that reads vector.json). +#[test] +fn vector_has_public_key_pem() { + let vector = load_vector(); + let pem = vector["publicKeyPem"] + .as_str() + .expect("vector.json must include publicKeyPem"); + assert!( + pem.contains("BEGIN PUBLIC KEY"), + "publicKeyPem should be a PEM block: {pem}" + ); +}