From 6100ebbe9494966409427ea638f98395f877175d Mon Sep 17 00:00:00 2001 From: Ian Jackson Date: Sat, 29 May 2021 22:56:58 +0100 Subject: [PATCH] sshkeys: module for trackng auth keys, still unfinished Signed-off-by: Ian Jackson --- daemon/cmdlistener.rs | 3 +- src/accounts.rs | 13 ++ src/commands.rs | 7 + src/config.rs | 19 ++ src/lib.rs | 1 + src/prelude.rs | 2 + src/sshkeys.rs | 404 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 448 insertions(+), 1 deletion(-) create mode 100644 src/sshkeys.rs diff --git a/daemon/cmdlistener.rs b/daemon/cmdlistener.rs index 5a953c51..7395a695 100644 --- a/daemon/cmdlistener.rs +++ b/daemon/cmdlistener.rs @@ -164,8 +164,9 @@ fn execute_and_respond(cs: &mut CommandStreamData, cmd: MgmtCommand, let layout = layout.unwrap_or_default(); let record = AccountRecord { account, nick, access, - timezone: timezone.unwrap_or_default(), layout, + timezone: timezone.unwrap_or_default(), + ssh_keys: default(), }; ag.insert_entry(record, auth)?; Fine diff --git a/src/accounts.rs b/src/accounts.rs index 9c12ca23..cf7f5560 100644 --- a/src/accounts.rs +++ b/src/accounts.rs @@ -46,6 +46,7 @@ pub struct AccountsGuard(MutexGuard<'static, Option>); pub struct Accounts { names: HashMap, AccountId>, records: DenseSlotMap, + #[serde(default)] ssh_keys: sshkeys::Global, } #[derive(Serialize,Deserialize,Debug)] @@ -55,6 +56,14 @@ pub struct AccountRecord { pub timezone: String, pub access: AccessRecord, pub layout: PresentationLayout, + #[serde(default)] // not relevant other than in entry for scope itself + pub ssh_keys: sshkeys::PerScope, +} + +#[derive(Serialize,Deserialize,Debug)] +pub struct AccountSshKey { + pub id: sshkeys::Id, + pub comment: sshkeys::Comment, } #[derive(Clone,Debug,Hash,Ord,PartialOrd,Eq,PartialEq)] @@ -425,6 +434,10 @@ impl AccountsGuard { f.close()?; fs::rename(&tmp, &main)?; } + + pub fn ssh_keys_mut(&mut self) -> &mut sshkeys::Global { + &mut self.0.get_or_insert_with(default).ssh_keys + } } //---------- load/save ---------- diff --git a/src/commands.rs b/src/commands.rs index 056ce1a2..c3cc997a 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -63,6 +63,10 @@ pub enum MgmtCommand { //---------- Accounts file ---------- +#[derive(Debug,Clone,Hash,Eq,PartialEq)] +#[derive(Serialize,Deserialize)] +pub struct SshFingerprint(pub String); + #[derive(Debug,Serialize,Deserialize)] pub struct AccountDetails { pub account: AccountName, @@ -254,6 +258,9 @@ pub enum MgmtError { #[error("game contains invalid UTF-8")] GameSpecInvalidData, #[error("idle timeout waiting for mgmt command")] IdleTimeout, #[error("upload took too long (timed out)")] UploadTimeout, + #[error("ssh key not found")] SshKeyNotFound, + #[error("ssh key id default, ie invalid")] InvalidSshKeyId, + #[error("ssh key invalid: {0}")] InvalidSshKey(#[from] sshkeys::KeyError), } impl From for MgmtError { diff --git a/src/config.rs b/src/config.rs index 952c022c..29784ca2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -38,6 +38,9 @@ pub struct ServerConfigSpec { pub shapelibs: Option>, pub specs_dir: Option, pub sendmail: Option, + pub ssh_proxy_bin: Option, + pub authorized_keys: Option, + pub authorized_keys_include: Option, pub debug_js_inject_file: Option, #[serde(default)] pub fake_rng: FakeRngSpec, /// Disable this for local testing only. See LICENCE. @@ -68,6 +71,9 @@ pub struct ServerConfig { pub shapelibs: Vec, pub specs_dir: String, pub sendmail: String, + pub ssh_proxy_bin: String, + pub authorized_keys: String, + pub authorized_keys_include: String, pub debug_js_inject: Arc, pub check_bundled_sources: bool, pub game_rng: RngWrap, @@ -120,9 +126,11 @@ impl ServerConfigSpec { template_dir, specs_dir, nwtemplate_dir, wasm_dir, libexec_dir, usvg_bin, log, bundled_sources, shapelibs, sendmail, debug_js_inject_file, check_bundled_sources, fake_rng, + ssh_proxy_bin, authorized_keys, authorized_keys_include, } = self; let game_rng = fake_rng.make_game_rng(); + let home = || env::var("HOME").context("HOME"); let prctx; if let Some(ref cd) = change_directory { @@ -153,6 +161,16 @@ impl ServerConfigSpec { specd.unwrap_or_else(|| format!("{}/{}", &libexec_dir, leaf)) }; let usvg_bin = in_libexec(usvg_bin, "usvg" ); + let ssh_proxy_bin = in_libexec(ssh_proxy_bin,"otter-ssh-proxy" ); + + let authorized_keys = if let Some(ak) = authorized_keys { ak } else { + let home = home().context("for authorized_keys")?; + // we deliberately don't create the ~/.ssh dir + format!("{}/.ssh/authorized_keys", home) + }; + let authorized_keys_include = authorized_keys_include.unwrap_or_else( + || format!("{}.static", authorized_keys) + ); let shapelibs = shapelibs.unwrap_or_else(||{ let glob = defpath(None, DEFAULT_LIBRARY_GLOB); @@ -235,6 +253,7 @@ impl ServerConfigSpec { template_dir, specs_dir, nwtemplate_dir, wasm_dir, libexec_dir, bundled_sources, shapelibs, sendmail, usvg_bin, debug_js_inject, check_bundled_sources, game_rng, prctx, + ssh_proxy_bin, authorized_keys, authorized_keys_include, }; trace_dbg!("config resolved", &server); Ok(WholeServerConfig { diff --git a/src/lib.rs b/src/lib.rs index ef9209ff..50123225 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -35,6 +35,7 @@ pub mod pieces; pub mod progress; pub mod shapelib; pub mod spec; +pub mod sshkeys; pub mod sse; pub mod termprogress; pub mod timedfd; diff --git a/src/prelude.rs b/src/prelude.rs index fbb3c86b..fe747571 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -36,6 +36,7 @@ pub use std::num::{NonZeroUsize, TryFromIntError, Wrapping}; pub use std::os::linux::fs::MetadataExt; // todo why linux for st_mode?? pub use std::os::unix; pub use std::os::unix::ffi::OsStrExt; +pub use std::os::unix::fs::OpenOptionsExt; pub use std::os::unix::io::{AsRawFd, IntoRawFd, RawFd}; pub use std::os::unix::net::UnixStream; pub use std::os::unix::process::{CommandExt, ExitStatusExt}; @@ -161,6 +162,7 @@ pub use crate::slotmap_slot_idx::*; pub use crate::spec::*; pub use crate::spec::piece_specs::{FaceColourSpecs, SimpleCommon}; pub use crate::sse; +pub use crate::sshkeys; pub use crate::toml_de; pub use crate::timedfd::*; pub use crate::termprogress; diff --git a/src/sshkeys.rs b/src/sshkeys.rs new file mode 100644 index 00000000..b72e1527 --- /dev/null +++ b/src/sshkeys.rs @@ -0,0 +1,404 @@ +// Copyright 2020-2021 Ian Jackson and contributors to Otter +// SPDX-License-Identifier: AGPL-3.0-or-later +// There is NO WARRANTY. + +use crate::prelude::*; + +visible_slotmap_key!{ Id(b'k') } + +static RESTRICTIONS: &str = + concat!("restrict,no-agent-forwarding,no-port-forwarding,", + "no-pty,no-user-rc,no-X11-forwarding"); + +static MAGIC_BANNER: &str = + "# WARNING - AUTOMATICALLY GENERAED FILE - DO NOT EDIT\n"; + +#[derive(Copy,Clone,Serialize,Deserialize,Eq,PartialEq)] +#[serde(transparent)] +// This will detecte if the slotmap in Accounts gets rewound, without +// updating the authorzed keys. That might reuse Id values but it +// can't reuse Nonces. +pub struct Nonce([u8; 32]); + +// States of a key wrt a particular scope: +// +// PerScope in authorized_keys leftover key +// core file a.k._dirty refcount==0 +// if us only core file +// +// ABSENT - - maybe[1] - - - +// GARBAGE - - maybe[1] - - y [2] +// **undesriable** - - - y - +// **illegal** - y +// UNSAVED y - maybe true n/a n/a +// BROKEN y y maybe true n/a n/a +// PRESENT y y y - n/a n/a +// +// [1] garbage in the authorised_keys is got rid of next time we +// write it, so it does not persist indefinitely. +// [2] garbage in Global is deleted when we do the key hashing (which +// iterates over all keys), which will happen before we add any +// key. + +#[derive(Debug,Clone,Serialize,Deserialize,Default)] +pub struct Global { + keys: DenseSlotMap, + authkeys_dirty: bool, + #[serde(skip)] fps: Option, +} +type FingerprintMap = HashMap, Id>; + +#[derive(Debug,Clone,Serialize,Deserialize)] +pub struct Key { + refcount: usize, + data: PubData, + nonce: Nonce, + #[serde(skip)] fp: Option, KeyError>>, +} + +#[derive(Debug,Clone,Serialize,Deserialize,Default)] +pub struct PerScope { + authorised: Vec>, +} +#[derive(Debug,Clone,Serialize,Deserialize)] +pub struct ScopeKey { + id: Id, // owns a refcount + comment: Comment, +} + +mod veneer { + // openssh_keys's API is a little odd. We make our own mini-API. + use crate::prelude::*; + extern crate openssh_keys; + use openssh_keys::errors::OpenSSHKeyError; + + // A line in nssh authorized keys firmat. Might have comment or + // options. Might or might not have a newline. String inside + // this newtype has not necessarily been syntax checked. + #[derive(Debug,Clone,Serialize,Deserialize)] + #[serde(transparent)] + pub struct AuthkeysLine(pub String); + + // In nssh authorized keys firmat. No options, no comment. + #[derive(Debug,Clone,Serialize,Deserialize)] + #[serde(transparent)] + pub struct PubData(String); + + #[derive(Debug,Clone,Serialize,Deserialize)] + #[serde(transparent)] + pub struct Comment(String /* must not contain newline/cr */); + + // Not Serialize,Deserialize, so we can change the hash, etc. + #[derive(Debug,Clone,Hash,Eq,PartialEq,Ord,PartialOrd)] + pub struct Fingerprint(String); + + #[derive(Error,Debug,Clone,Serialize,Deserialize)] + pub enum KeyError { + #[error("bad key data: {0}")] BadData(String), + #[error("failed to save key data, possibly broken")] Dirty, + } + + impl From for KeyError { + fn from(e: OpenSSHKeyError) -> Self { KeyError::BadData(e.to_string()) } + } + + impl Display for PubData { + #[throws(fmt::Error)] + fn fmt(&self, f: &mut fmt::Formatter) { write!(f, "{}", &self.0)? } + } + + impl AuthkeysLine { + #[throws(KeyError)] + pub fn parse(&self) -> (PubData, Comment) { + let openssh_keys::PublicKey { + options:_, data, comment + } = self.0.parse()?; + let data = openssh_keys::PublicKey { + data, + options: None, + comment: None, + }; + (PubData(data.to_string()), Comment(comment.unwrap_or_default())) + } + } + + impl PubData { + #[throws(KeyError)] + pub fn fingerprint(&self) -> Fingerprint { + let k: openssh_keys::PublicKey = self.0.parse()?; + Fingerprint(k.fingerprint()) + } + } + + impl Display for Fingerprint { + #[throws(fmt::Error)] + fn fmt(&self, f: &mut Formatter) { write!(f, "{}", self.0)? } + } + +} +pub use veneer::*; + +format_by_fmt_hex!{Display, for Nonce, .0} +impl Debug for Nonce { + #[throws(fmt::Error)] + fn fmt(&self, f: &mut Formatter) { + write!(f,"Nonce[")?; + fmt_hex(f, &self.0)?; + write!(f,"]")?; + } +} + +impl PerScope { + pub fn check(&self, gl: &Global, id: Id, nonce: Nonce) + -> Option> { + for sk in &self.authorised { + if_chain!{ + if let Some(sk) = sk; + if sk.id == id; + if let Some(key) = gl.keys.get(sk.id); + if key.nonce == nonce; + then { return Some(Authorisation::authorise_any()) } + } + } + None + } +} + +#[derive(Debug,Clone,Serialize,Deserialize)] +pub struct MgmtKeyReport { + id: Id, + data: PubData, + comment: Comment, + problem: Option, +} + +impl PerScope { + pub fn keys(&self, gl: &Global) -> Vec { + let dirty_error = + if gl.authkeys_dirty { Some(KeyError::Dirty) } + else { None }; + self.authorised.iter().filter_map(|sk| Some({ + let sk = sk.as_ref()?; + let key = gl.keys.get(sk.id)?; + let problem = if let Some(Err(ref e)) = key.fp { Some(e) } + else { dirty_error.as_ref() }; + MgmtKeyReport { + id: sk.id, + data: key.data.clone(), + comment: sk.comment.clone(), + problem: problem.cloned(), + } + })) + .collect() + } + + // not a good idea to speicfy a problem, but "whatever" + #[throws(ME)] + pub fn add_key(&mut self, accounts: &mut AccountsGuard, + new_akl: AuthkeysLine) -> (usize, Id) { + let (data, comment) = new_akl.parse()?; + let fp = data.fingerprint().map_err(KeyError::from)?; + let gl = &mut accounts.ssh_keys_mut(); + let fp = Arc::new(fp); + let _ = gl.fps(); + let fpe = gl.fps.as_mut().unwrap().entry(fp.clone()); + // ABSENT + let id = { + let keys = &mut gl.keys; + *fpe.or_insert_with(||{ + keys.insert(Key { + data, + refcount: 0, + nonce: Nonce(thread_rng().gen()), + fp: Some(Ok(fp.clone())), + }) + }) + }; + // **undesirable** + let key = gl.keys.get_mut(id) + .ok_or_else(|| internal_error_bydebug(&(id, &fp)))?; + // GARBAGE + key.refcount += 1; + let new_sk = Some(ScopeKey { id, comment }); + let index = + if let Some((index,_)) = self.authorised.iter() + .find_position(|sk| sk.is_none()) + { + self.authorised[index] = new_sk; + index + } else { + let index = self.authorised.len(); + self.authorised.push(new_sk); + index + }; + gl.authkeys_dirty = true; + // UNSAVED + accounts.save_accounts_now()?; + // BROKEN + accounts.ssh_keys_mut().rewrite_authorized_keys()?; + // PRESENT + (index, id) + } + + #[throws(ME)] + pub fn remove_key(&mut self, accounts: &mut AccountsGuard, + index: usize, id: Id) { + let gl = &mut accounts.ssh_keys_mut(); + if id == default() { + throw!(ME::InvalidSshKeyId); + } + match self.authorised.get(index) { + Some(&Some(ScopeKey{id:tid,..})) if tid == id => { }, + _ => throw!(ME::SshKeyNotFound), /* [ABSEMT..GARBAGE] */ + } + let key = gl.keys.get_mut(id).ok_or_else(|| internal_logic_error( + format!("corrupted accounts db: key id {} missing", id)))?; + + // [UNSAVED..PRESENT] + + key.refcount -= 1; + let previously = mem::take(&mut self.authorised[index]); + // Now **illegal**, briefly, don't leave it like this! No-one can + // observe the illegal state since we have the accounts lock. If + // we abort, the in-core version vanishes, leaving a legal state. + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe( + || accounts.save_accounts_now() + )); + let gl = &mut accounts.ssh_keys_mut(); + let key = gl.keys.get_mut(id).expect("aargh!"); + + if ! matches!(r, Ok(Ok(()))) { + key.refcount += 1; + self.authorised[index] = previously; + // [UNSAVED..PRESENT] + match r { + Err(payload) => std::panic::resume_unwind(payload), + Ok(Err(e)) => throw!(e), + Ok(Ok(())) => panic!(), // handled that earlier + } + } + // [ABSENT..GARBAGE] + + if key.refcount == 0 { + let key = gl.keys.remove(id).unwrap(); + if let Some(Ok(ref fp)) = key.fp { + gl.fps().remove(fp); + } + } + // ABSENT + } +} + +impl Global { + fn fps(&mut self) -> &mut FingerprintMap { + let keys = &mut self.keys; + self.fps.get_or_insert_with(||{ + + let mut fps = FingerprintMap::default(); + let mut garbage = vec![]; + + for (id, key) in keys.iter_mut() { + if key.refcount == 0 { garbage.push(id); continue; } + + if_let!{ + Ok(fp) = { + let data = &key.data; + key.fp.get_or_insert_with( + || Ok(Arc::new(data.fingerprint()?)) + ) + }; + else continue; + } + + use hash_map::Entry::*; + match fps.entry(fp.clone()) { + Vacant(ve) => { ve.insert(id); }, + Occupied(mut oe) => { + error!("ssh key fingerprint collision! \ + fp={} newid={} oldid={} newdata={:?}", + &fp, id, oe.get(), &key.data); + oe.insert(Id::default()); + }, + } + } + + for id in garbage { keys.remove(id); } + + fps + + }) + } + + #[throws(InternalError)] + fn write_keys(&self, w: &mut BufWriter) { + for (id, key) in &self.keys { + let fp = match key.fp { Some(Ok(ref fp)) => fp, _ => continue }; + if key.refcount == 0 { continue } + writeln!(w, r#"{},command="{} --ssh-proxy {} {}" {} {}:{}"#, + RESTRICTIONS, + &config().ssh_proxy_bin, id, key.nonce, + &key.data, + key.refcount, &fp) + .context("write new auth keys")?; + } + } + + // Caller should make sure accounts are saved first, to avoid + // getting the authkeys_dirty bit wrong. + #[throws(InternalError)] + fn rewrite_authorized_keys(&mut self) { + let config = config(); + let path = &config.authorized_keys; + let tmp = format!("{}.tmp", &path); + + (||{ + let f = File::open(path).context("open")?; + let l = BufReader::new(f).lines().next() + .ok_or_else(|| anyhow!("no first line!"))? + .context("read first line")?; + if l != MAGIC_BANNER { + throw!(anyhow!( + "first line is not as expected (manually written/edited?)" + )); + } + Ok::<_,AE>(()) + })() + .with_context(|| path.clone()) + .context("check authorized_keys magic/banner")?; + + let mut f = fs::OpenOptions::new() + .write(true).truncate(true).create(true) + .mode(0o644) + .open(&tmp) + .with_context(|| tmp.clone()).context("open new auth keys file")?; + + let include = &config.authorized_keys_include; + + (||{ + let mut f = BufWriter::new(&mut f); + write!(f, "{}", MAGIC_BANNER)?; + writeln!(f, "# YOU MAY EDIT {:?} INSTEAD - THAT IS INCLUDED HERE", + include)?; + f.flush()?; + Ok::<_,io::Error>(()) + })().with_context(|| tmp.clone()).context("write header")?; + + if let Some(mut sf) = match File::open(include) { + Ok(y) => Some(y), + Err(e) if e.kind() == ErrorKind::NotFound => None, + Err(e) => throw!(AE::from(e).context(include.clone()) + .context("open static auth keys")), + } { + io::copy(&mut sf, &mut f).context("copy data into new auth keys")?; + } + + let mut f = BufWriter::new(f); + self.write_keys(&mut f)?; + f.flush().context("finish writing new auth keys")?; + + fs::rename(&tmp, &path).with_context(|| path.clone()) + .context("install new auth keys")?; + + self.authkeys_dirty = false; + } +} -- 2.30.2