149 lines
4.8 KiB
Rust
149 lines
4.8 KiB
Rust
|
|
/// Integration test: NKey authentication sign/verify round-trip.
|
||
|
|
///
|
||
|
|
/// Tests the full flow: key generation → signing → header injection → verification.
|
||
|
|
use async_nats::header::HeaderMap;
|
||
|
|
use bytes::Bytes;
|
||
|
|
use platform_nats::NKeyAuth;
|
||
|
|
|
||
|
|
/// Produce a `HeaderMap` with NKey authentication headers for the given payload.
|
||
|
|
fn signed_headers(seed: &str, payload: &[u8]) -> HeaderMap {
|
||
|
|
let kp = nkeys::KeyPair::from_seed(seed).unwrap();
|
||
|
|
let sig = kp.sign(payload).unwrap();
|
||
|
|
let sig_b64 = b64url_encode(&sig);
|
||
|
|
|
||
|
|
let pub_key = kp.public_key();
|
||
|
|
let mut headers = HeaderMap::new();
|
||
|
|
headers.insert("Nats-Nkey", pub_key.as_str());
|
||
|
|
headers.insert("Nats-Signature", sig_b64.as_str());
|
||
|
|
headers
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Base64url encode (no padding, URL-safe alphabet: `-` and `_`).
|
||
|
|
///
|
||
|
|
/// Packs input into 3-byte chunks, encodes each as four 6-bit Base64 characters.
|
||
|
|
/// Partial trailing chunks emit 2 or 3 characters (no `=` padding).
|
||
|
|
fn b64url_encode(data: &[u8]) -> String {
|
||
|
|
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||
|
|
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
|
||
|
|
for chunk in data.chunks(3) {
|
||
|
|
let b0 = chunk[0] as u32;
|
||
|
|
let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
|
||
|
|
let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
|
||
|
|
let n = (b0 << 16) | (b1 << 8) | b2;
|
||
|
|
out.push(CHARS[((n >> 18) & 0x3F) as usize] as char);
|
||
|
|
out.push(CHARS[((n >> 12) & 0x3F) as usize] as char);
|
||
|
|
if chunk.len() > 1 {
|
||
|
|
out.push(CHARS[((n >> 6) & 0x3F) as usize] as char);
|
||
|
|
}
|
||
|
|
if chunk.len() > 2 {
|
||
|
|
out.push(CHARS[(n & 0x3F) as usize] as char);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
out
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Generate a fresh user NKey seed for testing.
|
||
|
|
fn fresh_seed() -> String {
|
||
|
|
let kp = nkeys::KeyPair::new_user();
|
||
|
|
kp.seed().unwrap()
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_trusted_key_signature_accepted() {
|
||
|
|
let seed = fresh_seed();
|
||
|
|
let kp = nkeys::KeyPair::from_seed(&seed).unwrap();
|
||
|
|
let pub_key = kp.public_key();
|
||
|
|
|
||
|
|
let auth = NKeyAuth::new(vec![pub_key]);
|
||
|
|
let payload = Bytes::from_static(b"hello stratum");
|
||
|
|
let headers = signed_headers(&seed, &payload);
|
||
|
|
|
||
|
|
auth.verify_message(Some(&headers), &payload).unwrap();
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_untrusted_key_rejected() {
|
||
|
|
let trusted_seed = fresh_seed();
|
||
|
|
let trusted_kp = nkeys::KeyPair::from_seed(&trusted_seed).unwrap();
|
||
|
|
let auth = NKeyAuth::new(vec![trusted_kp.public_key()]);
|
||
|
|
|
||
|
|
// Sign with a different (untrusted) key
|
||
|
|
let attacker_seed = fresh_seed();
|
||
|
|
let payload = Bytes::from_static(b"malicious payload");
|
||
|
|
let headers = signed_headers(&attacker_seed, &payload);
|
||
|
|
|
||
|
|
let err = auth.verify_message(Some(&headers), &payload).unwrap_err();
|
||
|
|
assert!(
|
||
|
|
err.to_string().contains("untrusted publisher"),
|
||
|
|
"expected untrusted error, got: {err}"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_tampered_payload_rejected() {
|
||
|
|
let seed = fresh_seed();
|
||
|
|
let kp = nkeys::KeyPair::from_seed(&seed).unwrap();
|
||
|
|
let pub_key = kp.public_key();
|
||
|
|
|
||
|
|
let auth = NKeyAuth::new(vec![pub_key]);
|
||
|
|
let original_payload = Bytes::from_static(b"original payload");
|
||
|
|
let headers = signed_headers(&seed, &original_payload);
|
||
|
|
|
||
|
|
// Signature was for `original_payload`, but we verify against tampered one
|
||
|
|
let tampered = Bytes::from_static(b"tampered payload!!!");
|
||
|
|
let err = auth
|
||
|
|
.verify_message(Some(&headers), &tampered)
|
||
|
|
.unwrap_err();
|
||
|
|
assert!(
|
||
|
|
err.to_string().contains("signature verification failed")
|
||
|
|
|| err.to_string().contains("untrusted"),
|
||
|
|
"expected signature error, got: {err}"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_missing_headers_rejected() {
|
||
|
|
let auth = NKeyAuth::new(vec!["some-key".to_string()]);
|
||
|
|
let payload = Bytes::from_static(b"payload");
|
||
|
|
|
||
|
|
let err = auth.verify_message(None, &payload).unwrap_err();
|
||
|
|
assert!(
|
||
|
|
err.to_string().contains("no headers"),
|
||
|
|
"expected no-headers error, got: {err}"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_missing_nkey_header_rejected() {
|
||
|
|
let auth = NKeyAuth::new(vec!["some-key".to_string()]);
|
||
|
|
let payload = Bytes::from_static(b"payload");
|
||
|
|
|
||
|
|
let headers = HeaderMap::new(); // empty — no Nats-Nkey
|
||
|
|
let err = auth
|
||
|
|
.verify_message(Some(&headers), &payload)
|
||
|
|
.unwrap_err();
|
||
|
|
assert!(
|
||
|
|
err.to_string().contains("missing Nats-Nkey"),
|
||
|
|
"expected missing-header error, got: {err}"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_auth_not_required_when_no_trusted_keys() {
|
||
|
|
let auth = NKeyAuth::new(vec![]); // no trusted keys = auth not required
|
||
|
|
assert!(
|
||
|
|
!auth.is_auth_required(),
|
||
|
|
"empty trusted set must mean auth is not required"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_auth_required_when_trusted_keys_present() {
|
||
|
|
let kp = nkeys::KeyPair::new_user();
|
||
|
|
let auth = NKeyAuth::new(vec![kp.public_key()]);
|
||
|
|
assert!(
|
||
|
|
auth.is_auth_required(),
|
||
|
|
"non-empty trusted set must mean auth is required"
|
||
|
|
);
|
||
|
|
}
|