feat(auth,client-registry): add Noise IK client authentication with managed client registry and per-client ACL controls

This commit is contained in:
2026-03-29 17:04:27 +00:00
parent 187a69028b
commit 01a0d8b9f4
20 changed files with 1930 additions and 897 deletions

View File

@@ -3,8 +3,10 @@ use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use snow::Builder;
/// Noise protocol pattern: NK (client knows server pubkey, no client auth at Noise level)
const NOISE_PATTERN: &str = "Noise_NK_25519_ChaChaPoly_BLAKE2s";
/// Noise protocol pattern: IK (client presents static key, server authenticates client)
/// IK = Initiator's static key is transmitted; responder's Key is pre-known.
/// This provides mutual authentication: server verifies client identity via public key.
const NOISE_PATTERN: &str = "Noise_IK_25519_ChaChaPoly_BLAKE2s";
/// Generate a new Noise static keypair.
/// Returns (public_key_base64, private_key_base64).
@@ -22,18 +24,23 @@ pub fn generate_keypair_raw() -> Result<snow::Keypair> {
Ok(builder.generate_keypair()?)
}
/// Create a Noise NK initiator (client side).
/// The client knows the server's static public key.
pub fn create_initiator(server_public_key: &[u8]) -> Result<snow::HandshakeState> {
/// Create a Noise IK initiator (client side).
/// The client provides its own static keypair AND the server's public key.
/// The client's static key is transmitted (encrypted) during the handshake,
/// allowing the server to authenticate the client.
pub fn create_initiator(client_private_key: &[u8], server_public_key: &[u8]) -> Result<snow::HandshakeState> {
let builder = Builder::new(NOISE_PATTERN.parse()?);
let state = builder
.local_private_key(client_private_key)
.remote_public_key(server_public_key)
.build_initiator()?;
Ok(state)
}
/// Create a Noise NK responder (server side).
/// Create a Noise IK responder (server side).
/// The server uses its static private key.
/// After the handshake, call `get_remote_static()` on the HandshakeState
/// (before `into_transport_mode()`) to retrieve the client's public key.
pub fn create_responder(private_key: &[u8]) -> Result<snow::HandshakeState> {
let builder = Builder::new(NOISE_PATTERN.parse()?);
let state = builder
@@ -42,19 +49,20 @@ pub fn create_responder(private_key: &[u8]) -> Result<snow::HandshakeState> {
Ok(state)
}
/// Perform the full Noise NK handshake between initiator and responder.
/// Returns (initiator_transport, responder_transport).
/// Perform the full Noise IK handshake between initiator and responder.
/// Returns (initiator_transport, responder_transport, client_public_key).
/// The client_public_key is extracted from the responder before entering transport mode.
pub fn perform_handshake(
mut initiator: snow::HandshakeState,
mut responder: snow::HandshakeState,
) -> Result<(snow::TransportState, snow::TransportState)> {
) -> Result<(snow::TransportState, snow::TransportState, Vec<u8>)> {
let mut buf = vec![0u8; 65535];
// -> e, es (initiator sends)
// -> e, es, s, ss (initiator sends ephemeral + encrypted static key)
let len = initiator.write_message(&[], &mut buf)?;
let msg1 = buf[..len].to_vec();
// <- e, ee (responder reads and responds)
// <- e, ee, se (responder reads and responds)
responder.read_message(&msg1, &mut buf)?;
let len = responder.write_message(&[], &mut buf)?;
let msg2 = buf[..len].to_vec();
@@ -62,10 +70,16 @@ pub fn perform_handshake(
// Initiator reads response
initiator.read_message(&msg2, &mut buf)?;
// Extract client's public key from responder BEFORE entering transport mode
let client_public_key = responder
.get_remote_static()
.ok_or_else(|| anyhow::anyhow!("IK handshake did not provide client static key"))?
.to_vec();
let i_transport = initiator.into_transport_mode()?;
let r_transport = responder.into_transport_mode()?;
Ok((i_transport, r_transport))
Ok((i_transport, r_transport, client_public_key))
}
/// XChaCha20-Poly1305 encryption for post-handshake data.
@@ -135,15 +149,19 @@ mod tests {
}
#[test]
fn noise_handshake() {
fn noise_ik_handshake() {
let server_kp = generate_keypair_raw().unwrap();
let client_kp = generate_keypair_raw().unwrap();
let initiator = create_initiator(&server_kp.public).unwrap();
let initiator = create_initiator(&client_kp.private, &server_kp.public).unwrap();
let responder = create_responder(&server_kp.private).unwrap();
let (mut i_transport, mut r_transport) =
let (mut i_transport, mut r_transport, remote_key) =
perform_handshake(initiator, responder).unwrap();
// Verify the server received the client's public key
assert_eq!(remote_key, client_kp.public);
// Test encrypted communication
let mut buf = vec![0u8; 65535];
let plaintext = b"hello from client";
@@ -159,6 +177,20 @@ mod tests {
assert_eq!(&out[..len], plaintext);
}
#[test]
fn noise_ik_wrong_server_key_fails() {
let server_kp = generate_keypair_raw().unwrap();
let wrong_server_kp = generate_keypair_raw().unwrap();
let client_kp = generate_keypair_raw().unwrap();
// Client uses wrong server public key
let initiator = create_initiator(&client_kp.private, &wrong_server_kp.public).unwrap();
let responder = create_responder(&server_kp.private).unwrap();
// Handshake should fail because client targeted wrong server
assert!(perform_handshake(initiator, responder).is_err());
}
#[test]
fn xchacha_encrypt_decrypt() {
let key = [42u8; 32];