config: accept oper password=<hash> named token; keep positional password back-compat
This commit is contained in:
parent
c2e0a9eeb4
commit
e8d16a1f96
2 changed files with 109 additions and 16 deletions
|
|
@ -55,7 +55,11 @@ bind_server = 0.0.0.0:7000
|
||||||
# webirc = CHANGE_THIS_WEBIRC_SECRET mygateway 203.0.113.9
|
# webirc = CHANGE_THIS_WEBIRC_SECRET mygateway 203.0.113.9
|
||||||
|
|
||||||
# IRC operators — oper = <name> <password> [level] [fp=<sha256>] [type=<id>]
|
# IRC operators — oper = <name> <password> [level] [fp=<sha256>] [type=<id>]
|
||||||
|
# The password may be positional (as below) or a named password=<hash> token;
|
||||||
|
# both accept plaintext, sha256:<hex>, pbkdf2:… or a bcrypt $2b$ hash. Generate a
|
||||||
|
# bcrypt hash with: printf '%s' 'yourpassword' | ./echoircd mkpasswd
|
||||||
oper = admin CHANGE_THIS_PASSWORD
|
oper = admin CHANGE_THIS_PASSWORD
|
||||||
|
# oper = admin password=$2b$11$…hash… fp=<sha256> type=netadmin
|
||||||
|
|
||||||
# Oper types (optional). An oper with no type= keeps full access. A type is a
|
# Oper types (optional). An oper with no type= keeps full access. A type is a
|
||||||
# named role (the WHOIS "is a <title>") built from reusable capability classes;
|
# named role (the WHOIS "is a <title>") built from reusable capability classes;
|
||||||
|
|
|
||||||
121
src/config.rs
121
src/config.rs
|
|
@ -5,9 +5,10 @@
|
||||||
//! network = echoNet
|
//! network = echoNet
|
||||||
//! bind = 127.0.0.1:6767
|
//! bind = 127.0.0.1:6767
|
||||||
//! motd = Welcome to echoIRCd
|
//! motd = Welcome to echoIRCd
|
||||||
//! oper = god secret # name + password (+ optional level)
|
//! oper = god secret # name + password (+ optional level)
|
||||||
|
//! oper = god password=<hash> # same, named form (fp=/type= style)
|
||||||
//! oper = god * fp=<sha256-cert-fp> # cert-only login (no password)
|
//! oper = god * fp=<sha256-cert-fp> # cert-only login (no password)
|
||||||
//! oper = god secret fp=<sha256-cert-fp> # password AND matching cert
|
//! oper = god password=<hash> fp=<cert-fp> # password AND matching cert
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use crate::map::HashMap;
|
use crate::map::HashMap;
|
||||||
|
|
@ -20,6 +21,16 @@ pub fn yesish(v: &str) -> bool {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether an oper token is a `key=value` option rather than the positional
|
||||||
|
/// password (so `oper = <name> password=<hash> fp=<fp>` isn't misread as having
|
||||||
|
/// the literal password `password=<hash>`).
|
||||||
|
fn is_oper_option(tok: &str) -> bool {
|
||||||
|
tok.starts_with("password=")
|
||||||
|
|| tok.starts_with("fp=")
|
||||||
|
|| tok.starts_with("certfp=")
|
||||||
|
|| tok.starts_with("type=")
|
||||||
|
}
|
||||||
|
|
||||||
/// A server-link block: how to authenticate a peer named `name` (and, if
|
/// A server-link block: how to authenticate a peer named `name` (and, if
|
||||||
/// `autoconnect`, where to dial it). Passwords are the shared link secret.
|
/// `autoconnect`, where to dial it). Passwords are the shared link secret.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -31,9 +42,11 @@ pub struct LinkBlock {
|
||||||
pub autoconnect: bool,
|
pub autoconnect: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An oper login: `oper = <name> <password|*> [level] [fp=<sha256-fingerprint>]`.
|
/// An oper login: `oper = <name> [<password|*> | password=<hash>] [level]
|
||||||
/// `password = *` means no password is checked (cert-only login); a `fp=` token
|
/// [fp=<sha256-fingerprint>] [type=<id>]`. The password may be given positionally
|
||||||
/// requires the user's TLS client-certificate SHA-256 fingerprint to match.
|
/// or as a `password=` token; `*` (or an absent password with a `fp=`) means no
|
||||||
|
/// password is checked (cert-only login); a `fp=` token requires the user's TLS
|
||||||
|
/// client-certificate SHA-256 fingerprint to match.
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
pub struct OperBlock {
|
pub struct OperBlock {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|
@ -227,20 +240,28 @@ impl Config {
|
||||||
}
|
}
|
||||||
"motd" => c.motd.push(v.to_string()),
|
"motd" => c.motd.push(v.to_string()),
|
||||||
"oper" => {
|
"oper" => {
|
||||||
let mut it = v.split_whitespace();
|
let mut it = v.split_whitespace().peekable();
|
||||||
if let (Some(n), Some(p)) = (it.next(), it.next()) {
|
if let Some(n) = it.next() {
|
||||||
let mut b = OperBlock {
|
let mut b = OperBlock {
|
||||||
name: n.to_string(),
|
name: n.to_string(),
|
||||||
password: p.to_string(),
|
..Default::default()
|
||||||
level: 0,
|
|
||||||
fingerprint: None,
|
|
||||||
oper_type: None,
|
|
||||||
};
|
};
|
||||||
// trailing tokens (any order): a number is the operlevel, a
|
// Back-compat: a bare second token (not a `key=value` option)
|
||||||
// `fp=`/`certfp=` token is the required TLS cert fingerprint,
|
// is the positional password (`*` = cert-only), matching the
|
||||||
// a `type=` token names the oper type (see modules::opertypes).
|
// old `oper = <name> <password|*> ...` form.
|
||||||
|
if let Some(tok) = it.peek() {
|
||||||
|
if !is_oper_option(tok) {
|
||||||
|
b.password = tok.to_string();
|
||||||
|
it.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Named options in any order: `password=` (preferred),
|
||||||
|
// `fp=`/`certfp=` the required TLS cert fingerprint, `type=`
|
||||||
|
// the oper type; a bare number is the operlevel.
|
||||||
for tok in it {
|
for tok in it {
|
||||||
if let Some(fp) =
|
if let Some(pw) = tok.strip_prefix("password=") {
|
||||||
|
b.password = pw.to_string();
|
||||||
|
} else if let Some(fp) =
|
||||||
tok.strip_prefix("fp=").or_else(|| tok.strip_prefix("certfp="))
|
tok.strip_prefix("fp=").or_else(|| tok.strip_prefix("certfp="))
|
||||||
{
|
{
|
||||||
b.fingerprint = Some(fp.to_ascii_lowercase());
|
b.fingerprint = Some(fp.to_ascii_lowercase());
|
||||||
|
|
@ -250,7 +271,12 @@ impl Config {
|
||||||
b.level = l;
|
b.level = l;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.opers.push(b);
|
// A block with no credential at all (no password, no cert
|
||||||
|
// fingerprint) would let anyone oper up — refuse it, as the
|
||||||
|
// old parser did by requiring a password token.
|
||||||
|
if !b.password.is_empty() || b.fingerprint.is_some() {
|
||||||
|
c.opers.push(b);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// +G censor word: `badword = <find> [replace]` (no replace ⇒ block)
|
// +G censor word: `badword = <find> [replace]` (no replace ⇒ block)
|
||||||
|
|
@ -326,3 +352,66 @@ impl Config {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn opers(text: &str) -> Vec<OperBlock> {
|
||||||
|
let mut c = Config::default();
|
||||||
|
Config::parse_into(&mut c, text);
|
||||||
|
c.opers
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn positional_password_still_parses() {
|
||||||
|
let o = opers("oper = god secret 5 fp=ABC type=netadmin");
|
||||||
|
assert_eq!(o.len(), 1);
|
||||||
|
assert_eq!(o[0].name, "god");
|
||||||
|
assert_eq!(o[0].password, "secret");
|
||||||
|
assert_eq!(o[0].level, 5);
|
||||||
|
assert_eq!(o[0].fingerprint.as_deref(), Some("abc"));
|
||||||
|
assert_eq!(o[0].oper_type.as_deref(), Some("netadmin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn star_positional_is_cert_only() {
|
||||||
|
let o = opers("oper = god * fp=abc");
|
||||||
|
assert_eq!(o[0].password, "*");
|
||||||
|
assert_eq!(o[0].fingerprint.as_deref(), Some("abc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn numeric_positional_password_is_not_a_level() {
|
||||||
|
// a bare second token is the password even when numeric (back-compat)
|
||||||
|
let o = opers("oper = god 12345");
|
||||||
|
assert_eq!(o[0].password, "12345");
|
||||||
|
assert_eq!(o[0].level, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn named_password_token() {
|
||||||
|
let o = opers("oper = reverse password=$2b$11$abc.def/ghi fp=FF type=netadmin");
|
||||||
|
assert_eq!(o.len(), 1);
|
||||||
|
assert_eq!(o[0].name, "reverse");
|
||||||
|
assert_eq!(o[0].password, "$2b$11$abc.def/ghi");
|
||||||
|
assert_eq!(o[0].fingerprint.as_deref(), Some("ff"));
|
||||||
|
assert_eq!(o[0].oper_type.as_deref(), Some("netadmin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn named_password_order_independent() {
|
||||||
|
let o = opers("oper = reverse type=admin fp=aa password=hunter2 3");
|
||||||
|
assert_eq!(o[0].password, "hunter2");
|
||||||
|
assert_eq!(o[0].level, 3);
|
||||||
|
assert_eq!(o[0].fingerprint.as_deref(), Some("aa"));
|
||||||
|
assert_eq!(o[0].oper_type.as_deref(), Some("admin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn credential_less_block_is_rejected() {
|
||||||
|
// no password and no fp would let anyone oper up — must be dropped
|
||||||
|
assert!(opers("oper = nobody").is_empty());
|
||||||
|
assert!(opers("oper = nobody type=netadmin").is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue