diff options
| -rw-r--r-- | docs/access.md | 8 | ||||
| -rw-r--r-- | docs/example.toml | 10 | ||||
| -rw-r--r-- | src/access/challenge.rs | 114 | ||||
| -rw-r--r-- | src/api/login.rs | 3 | ||||
| -rw-r--r-- | src/config/mod.rs | 27 | ||||
| -rw-r--r-- | src/main.rs | 2 | ||||
| -rw-r--r-- | src/node.rs | 5 | ||||
| -rw-r--r-- | src/simtest/mod.rs | 2 | ||||
| -rw-r--r-- | src/state/mod.rs | 4 |
9 files changed, 148 insertions, 27 deletions
diff --git a/docs/access.md b/docs/access.md index 909d915..3541efd 100644 --- a/docs/access.md +++ b/docs/access.md @@ -125,9 +125,11 @@ jq -n --arg c "$c" --rawfile s sig '{user: "alice", challenge: $c, signature: $s curl -s -X POST localhost:3000/auth/login/key -H 'Content-Type: application/json' -d @- ``` -A challenge is the node's, signed by it, valid for five minutes, and taken once (the -node remembers which in memory: after a restart, one captured in its five minutes -could be taken once more; it goes over TLS, as passwords do); +A challenge is the node's, signed by it, valid for five minutes (`--challenge-lifetime`), +and taken once (the node remembers which in memory: after a restart, one captured in +its five minutes could be taken once more; it goes over TLS, as passwords do). A user +logs in by key at most 100 times on a node within those minutes +(`--key-logins-per-user`), so one holding a key can't keep the others out; the signature must be by one of the user's keys, as the registry the node holds then says, and the user enabled (`401` if not, saying why). A swarm without a registry has no keys (`409`). diff --git a/docs/example.toml b/docs/example.toml index d2d9a67..cce8780 100644 --- a/docs/example.toml +++ b/docs/example.toml @@ -95,6 +95,16 @@ htpasswd = "/etc/continuum/htpasswd" # logs in again. # session-lifetime = "7d" +# 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). +# challenge-lifetime = "5m" + +# 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. +# key-logins-per-user = 100 + # Where the reverse proxies whose user-header is believed connect from: IP addresses (or # ranges, as ADDRESS/BITS), and unix for the API's Unix sockets (bind-addr). On a host # with other users, leave out the addresses and have the proxy use a Unix socket: others 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. |
