aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/access/challenge.rs114
-rw-r--r--src/api/login.rs3
-rw-r--r--src/config/mod.rs27
-rw-r--r--src/main.rs2
-rw-r--r--src/node.rs5
-rw-r--r--src/simtest/mod.rs2
-rw-r--r--src/state/mod.rs4
7 files changed, 133 insertions, 24 deletions
diff --git a/src/access/challenge.rs b/src/access/challenge.rs
index 64cafac..e61ea85 100644
--- a/src/access/challenge.rs
+++ b/src/access/challenge.rs
@@ -23,9 +23,8 @@ use crate::identity::NodeKey;
pub(crate) const NAMESPACE: &str = "continuum-login";
/// What every challenge starts with.
const PREFIX: &str = "cc1";
-/// How long a challenge is valid (ms).
-pub(crate) const LIFETIME_MS: u64 = 5 * 60 * 1000;
-/// Used challenges kept at most: past it, logins by key wait for some to expire.
+/// Used challenges kept at most, of all users: past it, logins by key wait for some to
+/// expire. A user's own are fewer (`--key-logins-per-user`).
const MAX_USED: usize = 10_000;
#[derive(Debug, Serialize, Deserialize)]
@@ -41,13 +40,13 @@ fn message(payload: &[u8]) -> Vec<u8> {
message
}
-/// A new challenge by `key`'s node, valid from `now` for `LIFETIME_MS`; with when it
+/// A new challenge by `key`'s node, valid from `now` for `lifetime_ms`; with when it
/// expires.
-pub(crate) fn issue(key: &NodeKey, now: u64) -> (String, u64) {
+pub(crate) fn issue(key: &NodeKey, now: u64, lifetime_ms: u64) -> (String, u64) {
let payload = Payload {
node: key.id(),
nonce: registry::new_id(),
- exp: now.saturating_add(LIFETIME_MS),
+ exp: now.saturating_add(lifetime_ms),
};
let bytes = serde_json::to_vec(&payload).expect("a challenge serializes");
let signature = key.sign(&message(&bytes));
@@ -61,13 +60,15 @@ pub(crate) fn issue(key: &NodeKey, now: u64) -> (String, u64) {
)
}
-/// The challenges used, by nonce, with when each expires.
+/// The challenges used, by nonce, with when each expires and the user it logged in.
#[derive(Debug, Default)]
-pub(crate) struct Used(HashMap<String, u64>);
+pub(crate) struct Used(HashMap<String, (u64, String)>);
/// Takes `signature` (armored) over `challenge` as a login of `user`, whose registry
/// `doc` holds: a challenge of `key`'s node, unexpired at `now`, not used before (then
-/// used, in `used`), signed by one of `user`'s keys, `user` enabled. Why not, if not.
+/// used, in `used`), signed by one of `user`'s keys, `user` enabled, with fewer than
+/// `per_user` of its own still remembered. Why not, if not.
+#[allow(clippy::too_many_arguments)]
pub(crate) fn take(
doc: &impl ReadDoc,
key: &NodeKey,
@@ -76,6 +77,7 @@ pub(crate) fn take(
user: &str,
now: u64,
used: &mut Used,
+ per_user: usize,
) -> Result<(), String> {
let mut parts = challenge.split('.');
let (Some(PREFIX), Some(payload), Some(signed), None) =
@@ -102,10 +104,15 @@ pub(crate) fn take(
if payload.exp <= now {
return Err("the challenge expired: ask for another".to_owned());
}
- used.0.retain(|_, exp| *exp > now);
+ used.0.retain(|_, (exp, _)| *exp > now);
if used.0.contains_key(&payload.nonce) {
return Err("the challenge was used: ask for another".to_owned());
}
+ if used.0.values().filter(|(_, by)| by == user).count() >= per_user {
+ return Err(format!(
+ "{user} logged in by key too often of late: try again in a while"
+ ));
+ }
if used.0.len() >= MAX_USED {
return Err("too many logins by key at once: try again in a while".to_owned());
}
@@ -117,7 +124,7 @@ pub(crate) fn take(
if !entry.keys.values().any(|known| *known == signer) {
return Err(format!("{user} has no key {}", signer.fingerprint()));
}
- used.0.insert(payload.nonce, payload.exp);
+ used.0.insert(payload.nonce, (payload.exp, user.to_owned()));
Ok(())
}
@@ -163,20 +170,40 @@ mod tests {
let stranger = SigningKey::from_bytes(&[6; 32]);
let doc = registry_with(&key);
let mut used = Used::default();
- let (challenge, exp) = issue(&node, 1000);
- assert_eq!(exp, 1000 + LIFETIME_MS);
+ let (challenge, exp) = issue(&node, 1000, 300_000);
+ assert_eq!(exp, 301_000);
let signed = sshsig::sign(&key, NAMESPACE, challenge.as_bytes());
// Another user, a disabled one, an unknown one, another key: refused.
- assert!(take(&doc, &node, &challenge, &signed, "bob", 1001, &mut used).is_err());
- assert!(take(&doc, &node, &challenge, &signed, "carol", 1001, &mut used).is_err());
- assert!(take(&doc, &node, &challenge, &signed, "dave", 1001, &mut used).is_err());
+ assert!(
+ take(
+ &doc, &node, &challenge, &signed, "bob", 1001, &mut used, 100
+ )
+ .is_err()
+ );
+ assert!(
+ take(
+ &doc, &node, &challenge, &signed, "carol", 1001, &mut used, 100
+ )
+ .is_err()
+ );
+ assert!(
+ take(
+ &doc, &node, &challenge, &signed, "dave", 1001, &mut used, 100
+ )
+ .is_err()
+ );
let foreign = sshsig::sign(&stranger, NAMESPACE, challenge.as_bytes());
- assert!(take(&doc, &node, &challenge, &foreign, "alice", 1001, &mut used).is_err());
+ assert!(
+ take(
+ &doc, &node, &challenge, &foreign, "alice", 1001, &mut used, 100
+ )
+ .is_err()
+ );
// For another purpose: refused.
let elsewhere = sshsig::sign(&key, "continuum-value", challenge.as_bytes());
assert!(
take(
- &doc, &node, &challenge, &elsewhere, "alice", 1001, &mut used
+ &doc, &node, &challenge, &elsewhere, "alice", 1001, &mut used, 100
)
.is_err()
);
@@ -189,17 +216,58 @@ mod tests {
&signed,
"alice",
1001,
- &mut used
+ &mut used,
+ 100
+ )
+ .is_err()
+ );
+ assert!(
+ take(
+ &doc, &node, &challenge, &signed, "alice", exp, &mut used, 100
)
.is_err()
);
- assert!(take(&doc, &node, &challenge, &signed, "alice", exp, &mut used).is_err());
// Taken, once.
- assert!(take(&doc, &node, &challenge, &signed, "alice", 1001, &mut used).is_ok());
- assert!(take(&doc, &node, &challenge, &signed, "alice", 1002, &mut used).is_err());
+ assert!(
+ take(
+ &doc, &node, &challenge, &signed, "alice", 1001, &mut used, 100
+ )
+ .is_ok()
+ );
+ assert!(
+ take(
+ &doc, &node, &challenge, &signed, "alice", 1002, &mut used, 100
+ )
+ .is_err()
+ );
// Tampered with.
let tampered = challenge.replacen("cc1.", "cc1.e30", 1);
let resigned = sshsig::sign(&key, NAMESPACE, tampered.as_bytes());
- assert!(take(&doc, &node, &tampered, &resigned, "alice", 1001, &mut used).is_err());
+ assert!(
+ take(
+ &doc, &node, &tampered, &resigned, "alice", 1001, &mut used, 100
+ )
+ .is_err()
+ );
+ }
+
+ /// A user's logins by key, as many as it may within a challenge's lifetime; past
+ /// them it waits, until those remembered expire.
+ #[test]
+ fn logins_by_key_per_user() {
+ let node = NodeKey::from_seed([1; 32]);
+ let key = SigningKey::from_bytes(&[5; 32]);
+ let doc = registry_with(&key);
+ let mut used = Used::default();
+ let login = |now: u64, used: &mut Used| {
+ let (challenge, _) = issue(&node, now, 1_000);
+ let signed = sshsig::sign(&key, NAMESPACE, challenge.as_bytes());
+ take(&doc, &node, &challenge, &signed, "alice", now, used, 2)
+ };
+ assert!(login(10, &mut used).is_ok());
+ assert!(login(20, &mut used).is_ok());
+ assert!(login(30, &mut used).unwrap_err().contains("too often"));
+ // Those remembered expired: in again.
+ assert!(login(1_015, &mut used).is_ok());
}
}
diff --git a/src/api/login.rs b/src/api/login.rs
index 4b42178..e3f8d02 100644
--- a/src/api/login.rs
+++ b/src/api/login.rs
@@ -283,7 +283,7 @@ pub(crate) async fn challenge(State(state): State<AppState>) -> Result<Response,
.node_key
.as_ref()
.ok_or(ApiError(StatusCode::SERVICE_UNAVAILABLE, None))?;
- let (challenge, exp) = challenge::issue(key, now);
+ let (challenge, exp) = challenge::issue(key, now, state.challenge_lifetime.as_millis() as u64);
Ok(Json(json!({
"challenge": challenge,
"namespace": challenge::NAMESPACE,
@@ -340,6 +340,7 @@ pub(crate) async fn login_key(
&request.user,
now,
&mut used,
+ state.key_logins_per_user,
)
.map_err(|why| {
debug!(user = %request.user, reason = %why, "Refused a login by key");
diff --git a/src/config/mod.rs b/src/config/mod.rs
index 9d918f7..9279f40 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -145,6 +145,20 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[
"How long after a login (POST /auth/login) its tokens may be refreshed; then the user logs in again.",
),
option(
+ "challenge-lifetime",
+ Kind::Duration,
+ "DURATION",
+ Some("5m"),
+ "How long a challenge to log in by key (POST /auth/challenge) is valid: the node remembers each used until then, and counts a user's logins by key over it (key-logins-per-user).",
+ ),
+ option(
+ "key-logins-per-user",
+ Kind::Integer,
+ "COUNT",
+ Some("100"),
+ "Logins by key a user may make on this node within a challenge's lifetime (challenge-lifetime); past it, that user waits, others don't. 10 000 by all users together at most.",
+ ),
+ option(
"cors-origins",
Kind::List,
"ORIGIN,[ORIGIN...]",
@@ -594,6 +608,13 @@ pub(crate) struct Settings {
with = "crate::units::duration_option"
)]
pub(crate) session_lifetime: Duration,
+ #[serde(
+ default = "default_challenge_lifetime",
+ with = "crate::units::duration_option"
+ )]
+ pub(crate) challenge_lifetime: Duration,
+ #[serde(default = "default_key_logins_per_user")]
+ pub(crate) key_logins_per_user: usize,
#[serde(default)]
pub(crate) debug_api: bool,
#[serde(default = "default_log_format")]
@@ -757,6 +778,12 @@ fn default_token_lifetime() -> Duration {
fn default_session_lifetime() -> Duration {
Duration::from_secs(7 * 24 * 3600)
}
+fn default_challenge_lifetime() -> Duration {
+ Duration::from_secs(5 * 60)
+}
+fn default_key_logins_per_user() -> usize {
+ 100
+}
fn default_keepalive() -> Duration {
Duration::from_secs(60)
}
diff --git a/src/main.rs b/src/main.rs
index 105266b..3674998 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -259,6 +259,8 @@ async fn main() -> Result<()> {
sign_every: args.sign_every,
token_lifetime: args.token_lifetime,
session_lifetime: args.session_lifetime,
+ challenge_lifetime: args.challenge_lifetime,
+ key_logins_per_user: args.key_logins_per_user,
cors_origins: args.cors_origins.clone(),
files,
state_file,
diff --git a/src/node.rs b/src/node.rs
index a5e512e..8886f68 100644
--- a/src/node.rs
+++ b/src/node.rs
@@ -75,6 +75,9 @@ pub(crate) struct NodeConfig {
/// `--token-lifetime`, `--session-lifetime`.
pub(crate) token_lifetime: Duration,
pub(crate) session_lifetime: Duration,
+ /// `--challenge-lifetime`, `--key-logins-per-user`.
+ pub(crate) challenge_lifetime: Duration,
+ pub(crate) key_logins_per_user: usize,
/// `--cors-origins`.
pub(crate) cors_origins: Vec<String>,
pub(crate) files: Arc<Files>,
@@ -166,6 +169,8 @@ impl Node {
challenges: Default::default(),
token_lifetime: config.token_lifetime,
session_lifetime: config.session_lifetime,
+ challenge_lifetime: config.challenge_lifetime,
+ key_logins_per_user: config.key_logins_per_user,
cors_origins: Arc::new(config.cors_origins),
state_file: config.state_file,
document_file: config.document_file,
diff --git a/src/simtest/mod.rs b/src/simtest/mod.rs
index 7fdc3f0..0ebb0ae 100644
--- a/src/simtest/mod.rs
+++ b/src/simtest/mod.rs
@@ -220,6 +220,8 @@ impl Sim {
sign_every: Default::default(),
token_lifetime: Duration::from_secs(3600),
session_lifetime: Duration::from_secs(86400),
+ challenge_lifetime: Duration::from_secs(300),
+ key_logins_per_user: 100,
cors_origins: Vec::new(),
files: Arc::new(Files::default()),
state_file: None,
diff --git a/src/state/mod.rs b/src/state/mod.rs
index 87b7fe7..4fcf413 100644
--- a/src/state/mod.rs
+++ b/src/state/mod.rs
@@ -157,6 +157,10 @@ pub(crate) struct AppState {
pub(crate) challenges: Arc<std::sync::Mutex<crate::access::challenge::Used>>,
pub(crate) token_lifetime: Duration,
pub(crate) session_lifetime: Duration,
+ /// How long a challenge to log in by key is valid, and how many logins by key a user
+ /// may make within it (`access::challenge`).
+ pub(crate) challenge_lifetime: Duration,
+ pub(crate) key_logins_per_user: usize,
/// Origins whose pages may call this node's open endpoints (`api::cors`).
pub(crate) cors_origins: Arc<Vec<String>>,
/// This node's own files, and how much it read and wrote them.