use alloc::string::{String, ToString};
use alloc::vec::Vec;
use core::net::IpAddr;
use std::collections::HashMap;
use std::sync::Arc;
use purecrypto::ec::x25519::X25519PrivateKey;
use purecrypto::rng::OsRng;
use purecrypto::zeroize::Zeroizing;
use super::wire::{self, Knock};
use crate::error::{Error, Result};
use crate::hostkey::HostKey;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct KnockServerConfig {
pub clock_skew_ms: u64,
pub window_secs: u16,
pub token_timeout_secs: u16,
pub max_pending: u32,
pub knock_burst: u32,
pub knock_refill_ms: u64,
pub newsig1_burst: u32,
pub newsig1_refill_ms: u64,
pub verify_per_sec: u32,
pub max_prefixes_per_key: usize,
pub sig1_cache_size: usize,
pub key_cache_ttl_ms: u64,
pub key_cache_users: usize,
pub key_lookups_per_sec: u32,
}
impl Default for KnockServerConfig {
fn default() -> Self {
Self {
clock_skew_ms: 30_000,
window_secs: 60,
token_timeout_secs: 5,
max_pending: 2,
knock_burst: 5,
knock_refill_ms: 10_000,
newsig1_burst: 3,
newsig1_refill_ms: 60_000,
verify_per_sec: 1000,
max_prefixes_per_key: 64,
sig1_cache_size: 4,
key_cache_ttl_ms: 60_000,
key_cache_users: 4096,
key_lookups_per_sec: 50,
}
}
}
pub trait KnockKeySource: Send {
fn keys_for(&mut self, user: &str) -> Vec<Vec<u8>>;
}
#[derive(Debug, Clone, Default)]
pub struct KnockKeyTable {
users: HashMap<String, HashMap<[u8; 32], Vec<u8>>>,
}
impl KnockKeyTable {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, user: &str, key_blob: Vec<u8>) -> bool {
if user.is_empty() || user.len() > wire::MAX_USER_LEN {
return false;
}
if wire::max_sig_blob_len(&key_blob).is_none() {
return false;
}
self.users
.entry(user.to_string())
.or_default()
.insert(wire::key_fp(&key_blob), key_blob);
true
}
pub fn len(&self) -> usize {
self.users.values().map(|k| k.len()).sum()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
impl KnockKeySource for KnockKeyTable {
fn keys_for(&mut self, user: &str) -> Vec<Vec<u8>> {
self.users
.get(user)
.map(|k| k.values().cloned().collect())
.unwrap_or_default()
}
}
struct CachedKeys {
loaded_ms: u64,
keys: HashMap<[u8; 32], Vec<u8>>,
}
#[derive(Debug, Clone)]
struct Bucket {
tokens: u32,
burst: u32,
refill_ms: u64,
last_ms: u64,
}
impl Bucket {
fn new(burst: u32, refill_ms: u64, now_ms: u64) -> Self {
Self {
tokens: burst,
burst,
refill_ms: refill_ms.max(1),
last_ms: now_ms,
}
}
fn take(&mut self, now_ms: u64) -> bool {
if now_ms > self.last_ms {
let gained = (now_ms - self.last_ms) / self.refill_ms;
if gained > 0 {
self.tokens = (self.tokens as u64 + gained).min(self.burst as u64) as u32;
self.last_ms += gained * self.refill_ms;
}
if self.tokens == self.burst {
self.last_ms = now_ms;
}
}
if self.tokens > 0 {
self.tokens -= 1;
true
} else {
false
}
}
}
fn source_prefix(ip: IpAddr) -> [u8; 8] {
match ip.to_canonical() {
IpAddr::V4(v4) => {
let o = v4.octets();
[4, 0, 0, 0, o[0], o[1], o[2], o[3]]
}
IpAddr::V6(v6) => {
let mut p = [0u8; 8];
p.copy_from_slice(&v6.octets()[..8]);
p
}
}
}
#[derive(Debug)]
struct Entry {
sig1_cache: Vec<Vec<u8>>,
newsig1: Bucket,
last_nonce: u64,
last_knock_hash: [u8; 32],
last_accept: Option<(Vec<u8>, u64)>,
knock_buckets: Vec<([u8; 8], Bucket, u64)>,
pending: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum DropReason {
Malformed,
UnknownKey,
Clock,
BadSig1,
BadSig2,
Replay,
RateLimited,
TooManyPending,
NoHostKey,
}
#[derive(Debug)]
pub enum Verdict {
Drop(DropReason),
Resend(Vec<u8>),
Accept(PendingAccept),
}
#[derive(Debug)]
pub struct PendingAccept {
pub user: String,
pub key_fp: [u8; 32],
pub knock_hash: [u8; 32],
host_key: usize,
full_hostkey: bool,
eph_c: [u8; 32],
knock_len: usize,
}
pub struct CompletedAccept {
pub datagram: Vec<u8>,
pub token: Zeroizing<[u8; 32]>,
pub host_key_blob: Vec<u8>,
}
impl core::fmt::Debug for CompletedAccept {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("CompletedAccept")
.field("datagram_len", &self.datagram.len())
.finish_non_exhaustive()
}
}
pub type KnockHostKey = Arc<dyn HostKey + Send + Sync>;
pub struct KnockState {
cfg: KnockServerConfig,
source: Box<dyn KnockKeySource>,
key_cache: HashMap<String, CachedKeys>,
entries: HashMap<(String, [u8; 32]), Entry>,
host_keys: Vec<(KnockHostKey, Vec<u8>, [u8; 32])>,
verify: Bucket,
lookups: Bucket,
nonce_floor: u64,
}
fn host_key_rank(blob: &[u8]) -> u8 {
match crate::format::Reader::new(blob).read_string() {
Ok(b"ssh-ed25519") => 0,
Ok(n) if n.starts_with(b"ecdsa-") => 1,
_ => 2,
}
}
impl KnockState {
pub fn new(
cfg: KnockServerConfig,
source: impl KnockKeySource + 'static,
host_keys: Vec<KnockHostKey>,
now_ms: u64,
) -> Self {
let mut hk: Vec<_> = host_keys
.into_iter()
.filter_map(|k| {
let blob = k.public_blob();
wire::max_sig_blob_len(&blob)?;
let fp = wire::key_fp(&blob);
Some((k, blob, fp))
})
.collect();
hk.sort_by_key(|(_, blob, _)| host_key_rank(blob));
let verify = Bucket::new(
cfg.verify_per_sec.max(1),
(1000 / cfg.verify_per_sec.max(1) as u64).max(1),
now_ms,
);
let lookups = Bucket::new(
cfg.key_lookups_per_sec.max(1),
(1000 / cfg.key_lookups_per_sec.max(1) as u64).max(1),
now_ms,
);
Self {
cfg,
source: Box::new(source),
key_cache: HashMap::new(),
entries: HashMap::new(),
host_keys: hk,
verify,
lookups,
nonce_floor: now_ms,
}
}
pub fn config(&self) -> &KnockServerConfig {
&self.cfg
}
pub fn set_key_source(&mut self, source: impl KnockKeySource + 'static) {
self.source = Box::new(source);
self.key_cache.clear();
}
pub fn invalidate_keys(&mut self, user: Option<&str>) {
match user {
Some(u) => {
self.key_cache.remove(u);
}
None => self.key_cache.clear(),
}
}
fn load_user_keys(&mut self, user: &str, now_ms: u64) -> bool {
let ttl = self.cfg.key_cache_ttl_ms;
let cached = self.key_cache.get(user);
if let Some(c) = cached
&& now_ms >= c.loaded_ms
&& now_ms - c.loaded_ms < ttl
{
return true;
}
if !self.lookups.take(now_ms) {
return cached.is_some();
}
if cached.is_none() && self.key_cache.len() >= self.cfg.key_cache_users.max(1) {
let victim = self
.key_cache
.iter()
.min_by_key(|(_, c)| (!c.keys.is_empty(), c.loaded_ms))
.map(|(u, _)| u.clone());
if let Some(v) = victim {
self.key_cache.remove(&v);
}
}
let keys: HashMap<[u8; 32], Vec<u8>> = self
.source
.keys_for(user)
.into_iter()
.filter(|b| wire::max_sig_blob_len(b).is_some())
.map(|b| (wire::key_fp(&b), b))
.collect();
self.entries
.retain(|(u, fp), _| u != user || keys.contains_key(fp));
self.key_cache.insert(
user.to_string(),
CachedKeys {
loaded_ms: now_ms,
keys,
},
);
true
}
pub fn release_pending(&mut self, user: &str, key_fp: &[u8; 32]) {
if let Some(e) = self.entries.get_mut(&(user.to_string(), *key_fp)) {
e.pending = e.pending.saturating_sub(1);
}
}
pub fn handle(&mut self, now_ms: u64, src: IpAddr, datagram: &[u8]) -> Verdict {
let Ok(k) = Knock::parse(datagram) else {
return Verdict::Drop(DropReason::Malformed);
};
if k.nonce.abs_diff(now_ms) > self.cfg.clock_skew_ms {
return Verdict::Drop(DropReason::Clock);
}
if !self.load_user_keys(k.user, now_ms) {
return Verdict::Drop(DropReason::RateLimited);
}
let Some(key_blob) = self
.key_cache
.get(k.user)
.and_then(|c| c.keys.get(&k.key_fp))
else {
return Verdict::Drop(DropReason::UnknownKey);
};
let cfg = &self.cfg;
let entry = self
.entries
.entry((k.user.to_string(), k.key_fp))
.or_insert_with(|| Entry {
sig1_cache: Vec::new(),
newsig1: Bucket::new(cfg.newsig1_burst, cfg.newsig1_refill_ms, now_ms),
last_nonce: 0,
last_knock_hash: [0u8; 32],
last_accept: None,
knock_buckets: Vec::new(),
pending: 0,
});
if let Some(pos) = entry.sig1_cache.iter().position(|s| s == k.sig1) {
let hit = entry.sig1_cache.remove(pos);
entry.sig1_cache.insert(0, hit);
} else {
if !entry.newsig1.take(now_ms) || !self.verify.take(now_ms) {
return Verdict::Drop(DropReason::RateLimited);
}
let msg = wire::knock_id_message(k.user, &k.key_fp);
if wire::verify(key_blob, wire::NS_KNOCK_ID, &msg, k.sig1).is_err() {
return Verdict::Drop(DropReason::BadSig1);
}
entry.sig1_cache.insert(0, k.sig1.to_vec());
entry.sig1_cache.truncate(self.cfg.sig1_cache_size.max(1));
}
let hash = wire::knock_hash(datagram);
if k.nonce == entry.last_nonce && hash == entry.last_knock_hash {
if let Some((accept, expiry)) = &entry.last_accept
&& now_ms < *expiry
{
let accept = accept.clone();
if !Self::take_knock_bucket(&self.cfg, entry, src, now_ms) {
return Verdict::Drop(DropReason::RateLimited);
}
return Verdict::Resend(accept);
}
return Verdict::Drop(DropReason::Replay);
}
if k.nonce <= entry.last_nonce || k.nonce < self.nonce_floor {
return Verdict::Drop(DropReason::Replay);
}
if !Self::take_knock_bucket(&self.cfg, entry, src, now_ms) || !self.verify.take(now_ms) {
return Verdict::Drop(DropReason::RateLimited);
}
if wire::verify(key_blob, wire::NS_KNOCK, k.signed, k.sig2).is_err() {
return Verdict::Drop(DropReason::BadSig2);
}
entry.last_nonce = k.nonce;
entry.last_knock_hash = hash;
entry.last_accept = None;
if entry.pending >= self.cfg.max_pending {
return Verdict::Drop(DropReason::TooManyPending);
}
let need_full = k.flags & wire::FLAG_NEED_HOSTKEY != 0;
let fits = |blob: &[u8]| {
wire::accept_len_bound(blob, need_full).is_some_and(|n| n <= datagram.len())
};
let chosen = if need_full {
self.host_keys.iter().position(|(_, blob, _)| fits(blob))
} else {
self.host_keys
.iter()
.position(|(_, blob, fp)| *fp == k.host_fp && fits(blob))
};
let Some(host_key) = chosen else {
return Verdict::Drop(DropReason::NoHostKey);
};
entry.pending += 1;
Verdict::Accept(PendingAccept {
user: k.user.to_string(),
key_fp: k.key_fp,
knock_hash: hash,
host_key,
full_hostkey: need_full,
eph_c: k.eph_c,
knock_len: datagram.len(),
})
}
fn take_knock_bucket(cfg: &KnockServerConfig, e: &mut Entry, src: IpAddr, now_ms: u64) -> bool {
let prefix = source_prefix(src);
let idx = match e.knock_buckets.iter().position(|(p, _, _)| *p == prefix) {
Some(i) => i,
None => {
if e.knock_buckets.len() >= cfg.max_prefixes_per_key.max(1) {
let lru = e
.knock_buckets
.iter()
.enumerate()
.min_by_key(|(_, (_, _, used))| *used)
.map(|(i, _)| i)
.unwrap_or(0);
e.knock_buckets.swap_remove(lru);
}
e.knock_buckets.push((
prefix,
Bucket::new(cfg.knock_burst, cfg.knock_refill_ms, now_ms),
now_ms,
));
e.knock_buckets.len() - 1
}
};
let slot = &mut e.knock_buckets[idx];
slot.2 = now_ms;
slot.1.take(now_ms)
}
pub fn complete(
&mut self,
pending: &PendingAccept,
port: u16,
now_ms: u64,
) -> Result<CompletedAccept> {
let res = self.complete_inner(pending, port, now_ms);
if res.is_err() {
self.release_pending(&pending.user, &pending.key_fp);
}
res
}
fn complete_inner(
&mut self,
pending: &PendingAccept,
port: u16,
now_ms: u64,
) -> Result<CompletedAccept> {
let (host_key, blob, _) = self
.host_keys
.get(pending.host_key)
.ok_or(Error::Config("knock: host key index"))?;
let eph = X25519PrivateKey::generate(&mut OsRng);
let eph_s = eph.public_key();
let shared = Zeroizing::new(
eph.diffie_hellman(&pending.eph_c)
.map_err(|_| Error::Crypto("knock: small-order client ephemeral"))?,
);
let window = self.cfg.window_secs.max(1);
let datagram = wire::build_accept(
host_key.as_ref(),
&pending.knock_hash,
&eph_s,
port,
window,
pending.full_hostkey,
)?;
if datagram.len() > pending.knock_len {
return Err(Error::Protocol("knock: ACCEPT larger than KNOCK"));
}
let token = wire::open_token(&shared, &pending.knock_hash, &eph_s, port);
if let Some(e) = self
.entries
.get_mut(&(pending.user.clone(), pending.key_fp))
&& e.last_knock_hash == pending.knock_hash
{
e.last_accept = Some((datagram.clone(), now_ms + window as u64 * 1000));
}
Ok(CompletedAccept {
datagram,
token,
host_key_blob: blob.clone(),
})
}
}
impl core::fmt::Debug for KnockState {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("KnockState")
.field("cached_users", &self.key_cache.len())
.field("host_keys", &self.host_keys.len())
.finish_non_exhaustive()
}
}